diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index ef83dcd45a..6ba508640c 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import base64 from contextlib import asynccontextmanager import importlib import json @@ -1673,6 +1674,32 @@ async def list_metrics_info(app_name: str) -> ListMetricsInfoResponse: "/apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts/{artifact_name}", response_model_exclude_none=True, ) +def _sanitize_svg_content(svg_str: str) -> str: + """Remove XSS vectors from SVG content. + + Removes foreignObject, script tags, and event handlers. + """ + if not svg_str: + return svg_str + + # Remove foreignObject elements + svg_str = re.sub(r']*>.*?', '', svg_str, + flags=re.IGNORECASE | re.DOTALL) + # Remove script tags + svg_str = re.sub(r']*>.*?', '', svg_str, + flags=re.IGNORECASE | re.DOTALL) + # Remove event handler attributes + handlers = ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', + 'onkeydown', 'onkeyup', 'onchange', 'onsubmit', 'onfocus', 'onblur'] + for handler in handlers: + pattern = handler + r'\s*=\s*["']?[^"'> ]*["']?' + svg_str = re.sub(pattern, '', svg_str, flags=re.IGNORECASE) + # Remove javascript: URLs + svg_str = re.sub(r'javascript:', '', svg_str, flags=re.IGNORECASE) + + return svg_str + + async def load_artifact( app_name: str, user_id: str, @@ -1689,6 +1716,30 @@ async def load_artifact( ) if not artifact: raise HTTPException(status_code=404, detail="Artifact not found") + + # Sanitize SVG content to prevent XSS + try: + if artifact and hasattr(artifact, 'inline_data') and artifact.inline_data: + inline_data = artifact.inline_data + mime_type = getattr(inline_data, 'mime_type', '') + + if mime_type == 'image/svg+xml': + data = getattr(inline_data, 'data', None) + if data: + # Decode base64 + if isinstance(data, bytes): + svg_str = data.decode('utf-8') + else: + svg_str = base64.b64decode(data).decode('utf-8') + + # Sanitize + clean_svg = _sanitize_svg_content(svg_str) + + # Re-encode + inline_data.data = base64.b64encode(clean_svg.encode()).decode() + except Exception as e: + logger.warning(f"SVG sanitization skipped: {e}") + return artifact @app.get( diff --git a/tests/unittests/cli/test_svg_sanitization.py b/tests/unittests/cli/test_svg_sanitization.py new file mode 100644 index 0000000000..a9f3c43261 --- /dev/null +++ b/tests/unittests/cli/test_svg_sanitization.py @@ -0,0 +1,137 @@ +# 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. + +"""Tests for SVG sanitization to prevent Stored XSS.""" + +import pytest +from google.adk.cli.adk_web_server import _sanitize_svg_content + + +class TestSvgSanitization: + """Test SVG XSS sanitization.""" + + def test_remove_foreignobject(self) -> None: + """foreignObject elements are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert ' None: + """Script tags are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert ' None: + """onload event handlers are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert 'onload' not in result + + def test_remove_onclick_handler(self) -> None: + """onclick event handlers are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert 'onclick' not in result + + def test_remove_onerror_handler(self) -> None: + """onerror event handlers are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert 'onerror' not in result + + def test_remove_multiple_event_handlers(self) -> None: + """Multiple event handlers are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert 'onload' not in result + assert 'onclick' not in result + assert 'onmouseover' not in result + + def test_remove_javascript_urls(self) -> None: + """javascript: URLs are removed.""" + svg = 'Click' + result = _sanitize_svg_content(svg) + assert 'javascript:' not in result + + def test_preserve_valid_svg_structure(self) -> None: + """Valid SVG structure is preserved.""" + svg = '' + result = _sanitize_svg_content(svg) + assert ' None: + """Valid SVG attributes are preserved.""" + svg = '' + result = _sanitize_svg_content(svg) + assert 'xmlns=' in result + assert 'viewBox=' in result + assert 'width="100"' in result + assert 'height="100"' in result + + def test_preserve_svg_elements(self) -> None: + """Valid SVG elements are preserved.""" + svg = '' + result = _sanitize_svg_content(svg) + assert ' None: + """Empty SVG string returns empty.""" + svg = '' + result = _sanitize_svg_content(svg) + assert result == '' + + def test_none_input(self) -> None: + """None input is handled gracefully.""" + result = _sanitize_svg_content(None) + assert result is None + + def test_case_insensitive_removal(self) -> None: + """Event handlers with different cases are removed.""" + svg = '' + result = _sanitize_svg_content(svg) + assert 'onload' not in result.lower() or 'ONLOAD' not in result + assert 'onclick' not in result.lower() or 'OnClick' not in result + + def test_complex_xss_payload(self) -> None: + """Complex XSS payload from issue #5514 is blocked.""" + svg = ''' + + + +

XSS via foreignObject

+ +
+
''' + result = _sanitize_svg_content(svg) + assert ' None: + """Content outside dangerous elements is preserved.""" + svg = 'Safe content' + result = _sanitize_svg_content(svg) + assert '' in result + assert 'Safe content' in result + assert '