From 813a31b5eb6c05707eb70f75e149314990739726 Mon Sep 17 00:00:00 2001 From: jeongbeannnn Date: Wed, 29 Jul 2026 16:57:01 +0900 Subject: [PATCH] fix(cli): sanitize SVG artifacts before rendering --- pyproject.toml | 1 + src/google/adk/cli/api_server.py | 93 +++++++++++++++++++- tests/unittests/cli/test_svg_sanitization.py | 66 ++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/unittests/cli/test_svg_sanitization.py diff --git a/pyproject.toml b/pyproject.toml index 54d7e411c1..af8180beab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "graphviz>=0.20.2,<1", "httpx>=0.27,<1", "jsonschema>=4.23,<5", + "nh3>=0.3,<1", "opentelemetry-api>=1.39,<=1.42.1", "opentelemetry-sdk>=1.39,<=1.42.1", "packaging>=21", diff --git a/src/google/adk/cli/api_server.py b/src/google/adk/cli/api_server.py index 94aac65ae4..5fe7f6a24b 100644 --- a/src/google/adk/cli/api_server.py +++ b/src/google/adk/cli/api_server.py @@ -49,6 +49,7 @@ from fastapi.websockets import WebSocket from fastapi.websockets import WebSocketDisconnect from google.genai import types +import nh3 from opentelemetry import trace import opentelemetry.sdk.environment_variables as otel_env from opentelemetry.sdk.trace import export as export_lib @@ -97,6 +98,94 @@ logger = logging.getLogger("google_adk." + __name__) _REGEX_PREFIX = "regex:" +_SAFE_SVG_TAGS = { + "circle", + "clipPath", + "defs", + "desc", + "ellipse", + "g", + "image", + "line", + "linearGradient", + "marker", + "mask", + "path", + "polygon", + "polyline", + "radialGradient", + "rect", + "stop", + "svg", + "symbol", + "text", + "title", + "tspan", + "use", +} +_SAFE_SVG_ATTRIBUTES = { + "class", + "clip-path", + "cx", + "cy", + "d", + "fill", + "fill-opacity", + "font-family", + "font-size", + "font-weight", + "height", + "href", + "id", + "marker-end", + "marker-mid", + "marker-start", + "offset", + "opacity", + "points", + "preserveAspectRatio", + "r", + "rx", + "ry", + "stop-color", + "stop-opacity", + "stroke", + "stroke-dasharray", + "stroke-linecap", + "stroke-linejoin", + "stroke-opacity", + "stroke-width", + "text-anchor", + "transform", + "viewBox", + "width", + "x", + "x1", + "x2", + "xlink:href", + "y", + "y1", + "y2", +} + + +def _sanitize_svg_artifact(artifact: types.Part) -> types.Part: + """Sanitize SVG artifact data before returning it to a browser.""" + inline_data = artifact.inline_data + if not inline_data or inline_data.mime_type != "image/svg+xml": + return artifact + + try: + svg = inline_data.data.decode("utf-8") + except UnicodeDecodeError as exc: + raise HTTPException(status_code=422, detail="Invalid SVG encoding") from exc + + inline_data.data = nh3.clean( + svg, + tags=_SAFE_SVG_TAGS, + attributes={"*": _SAFE_SVG_ATTRIBUTES}, + ).encode("utf-8") + return artifact def _parse_cors_origins( @@ -1545,7 +1634,7 @@ async def load_artifact_version( ) if not artifact: raise HTTPException(status_code=404, detail="Artifact not found") - return artifact + return _sanitize_svg_artifact(artifact) @app.get( "/apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts", @@ -1595,7 +1684,7 @@ async def load_artifact( ) if not artifact: raise HTTPException(status_code=404, detail="Artifact not found") - return artifact + return _sanitize_svg_artifact(artifact) @app.delete( "/apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts/{artifact_name:path}", diff --git a/tests/unittests/cli/test_svg_sanitization.py b/tests/unittests/cli/test_svg_sanitization.py new file mode 100644 index 0000000000..59121ca94d --- /dev/null +++ b/tests/unittests/cli/test_svg_sanitization.py @@ -0,0 +1,66 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from google.adk.cli.api_server import _sanitize_svg_artifact +from google.genai import types +import pytest + + +@pytest.mark.parametrize( + "payload", + ( + '', + "", + '', + 'click', + ), +) +def test_sanitize_svg_artifact_blocks_xss(payload: str) -> None: + artifact = types.Part.from_bytes( + data=payload.encode(), mime_type="image/svg+xml" + ) + + sanitized = _sanitize_svg_artifact(artifact).inline_data.data.decode() + + assert "alert" not in sanitized + assert "foreignObject" not in sanitized + assert "script" not in sanitized + assert "onanimationend" not in sanitized + assert "javascript:" not in sanitized + + +def test_sanitize_svg_artifact_preserves_safe_svg() -> None: + artifact = types.Part.from_bytes( + data=( + b'' + b'' + b"" + ), + mime_type="image/svg+xml", + ) + + sanitized = _sanitize_svg_artifact(artifact).inline_data.data.decode() + + assert "linearGradient" in sanitized + assert 'viewBox="0 0 10 10"' in sanitized + assert '' in sanitized + + +def test_sanitize_svg_artifact_ignores_other_mime_types() -> None: + artifact = types.Part.from_bytes(data=b"unchanged", mime_type="image/png") + + assert _sanitize_svg_artifact(artifact) is artifact + assert artifact.inline_data.data == b"unchanged"