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",
+ (
+ '',
+ "",
+ '',
+ '',
+ ),
+)
+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'"
+ ),
+ 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"