Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/google/adk/cli/adk_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import annotations

import asyncio
import base64
from contextlib import asynccontextmanager
import importlib
import json
Expand Down Expand Up @@ -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'<foreignObject[^>]*>.*?</foreignObject>', '', svg_str,
flags=re.IGNORECASE | re.DOTALL)
# Remove script tags
svg_str = re.sub(r'<script[^>]*>.*?</script>', '', 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,
Expand All @@ -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(
Expand Down
137 changes: 137 additions & 0 deletions tests/unittests/cli/test_svg_sanitization.py
Original file line number Diff line number Diff line change
@@ -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 = '<svg><foreignObject><img onerror="alert(1)"/></foreignObject></svg>'
result = _sanitize_svg_content(svg)
assert '<foreignObject' not in result
assert 'onerror' not in result

def test_remove_script_tags(self) -> None:
"""Script tags are removed."""
svg = '<svg><script>alert(1)</script></svg>'
result = _sanitize_svg_content(svg)
assert '<script' not in result
assert 'alert' not in result

def test_remove_onload_handler(self) -> None:
"""onload event handlers are removed."""
svg = '<svg onload="alert(1)"><rect/></svg>'
result = _sanitize_svg_content(svg)
assert 'onload' not in result

def test_remove_onclick_handler(self) -> None:
"""onclick event handlers are removed."""
svg = '<svg><rect onclick="alert(1)"/></svg>'
result = _sanitize_svg_content(svg)
assert 'onclick' not in result

def test_remove_onerror_handler(self) -> None:
"""onerror event handlers are removed."""
svg = '<svg><img src=x onerror="alert(1)"/></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 = '<svg onload="a()" onclick="b()" onmouseover="c()"><rect/></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 = '<svg><a href="javascript:alert(1)">Click</a></svg>'
result = _sanitize_svg_content(svg)
assert 'javascript:' not in result

def test_preserve_valid_svg_structure(self) -> None:
"""Valid SVG structure is preserved."""
svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect x="10" y="10" width="100" height="100"/></svg>'
result = _sanitize_svg_content(svg)
assert '<svg' in result
assert '<rect' in result
assert 'width="100"' in result
assert 'height="100"' in result

def test_preserve_svg_attributes(self) -> None:
"""Valid SVG attributes are preserved."""
svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"></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 = '<svg><circle cx="50" cy="50" r="40"/><path d="M10 10 L90 90"/></svg>'
result = _sanitize_svg_content(svg)
assert '<circle' in result
assert 'cx="50"' in result
assert '<path' in result

def test_empty_svg(self) -> 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 = '<svg><rect ONLOAD="alert(1)" OnClick="alert(2)"/></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 = '''<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<foreignObject width="300" height="200">
<body xmlns="http://www.w3.org/1999/xhtml">
<img src=x onerror="alert('XSS-ADK-006-foreignObject')"/>
<h1 style="color:red">XSS via foreignObject</h1>
</body>
</foreignObject>
</svg>'''
result = _sanitize_svg_content(svg)
assert '<foreignObject' not in result
assert 'onerror' not in result
assert 'alert' not in result

def test_preserves_content_outside_dangerous_elements(self) -> None:
"""Content outside dangerous elements is preserved."""
svg = '<svg><text>Safe content</text><foreignObject><script>bad</script></foreignObject></svg>'
result = _sanitize_svg_content(svg)
assert '<text>' in result
assert 'Safe content' in result
assert '<foreignObject' not in result
assert '<script' not in result

Loading