From e7b83641532cc07f36ab259ffe4cb30d33134ff7 Mon Sep 17 00:00:00 2001 From: Edwin Voskamp Date: Tue, 14 Jul 2026 10:48:14 -0700 Subject: [PATCH] Escape and allowlist embedded image data URLs to prevent stored XSS The embedded-image path interpolated an image's media_type and base64 data straight into an attribute with no escaping, allowlist, or base64 validation. These fields come from transcript JSON and can carry content that did not originate from the user (tool/MCP- returned images, fetched web content), so a crafted media_type such as `png">` broke out of the src attribute and injected a live script that runs when the generated HTML is opened under file://. Add a shared _is_safe_image_source guard in image_export.py that allowlists the media type (png/jpeg/gif/webp, excluding scriptable SVG) and validates the base64, applied to both embedded and referenced modes. Escape the final src at both HTML sinks. This mirrors the guards already present on the tool-result image path. The markdown output path (![](data:...)) intentionally keeps the raw data URL but still benefits from the upstream allowlist and validation. Adds regression tests for the attribute-breakout media_type, SVG rejection, invalid base64, referenced-mode rejection, and end-to-end HTML-sink escaping. References: #277 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q2uhgREMMmpYR6AMH65whA --- claude_code_log/html/assistant_formatters.py | 5 +- claude_code_log/html/renderer.py | 5 +- claude_code_log/image_export.py | 61 ++++++++++++++- test/test_image_export.py | 78 ++++++++++++++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/claude_code_log/html/assistant_formatters.py b/claude_code_log/html/assistant_formatters.py index 3128a50c..02295cf9 100644 --- a/claude_code_log/html/assistant_formatters.py +++ b/claude_code_log/html/assistant_formatters.py @@ -108,7 +108,10 @@ def format_image_content(image: ImageContent) -> str: src = export_image(image, mode="embedded") if src is None: return "[Image]" - return f'image' + # Escape the src: export_image allowlists the media type and + # validates the base64, but the data: URL still must not be able to + # break out of the attribute (issue #277). + return f'image' def format_unknown_content(content: UnknownMessage) -> str: diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 92900896..3c8c4d55 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -473,7 +473,10 @@ def _format_image(self, image: ImageContent) -> str: ) if src is None: return "[Image]" - return f'image' + # Escape the src: export_image allowlists the media type and + # validates the base64, but the data: URL (embedded mode) still + # must not be able to break out of the attribute (issue #277). + return f'image' # ------------------------------------------------------------------------- # System Content Formatters diff --git a/claude_code_log/image_export.py b/claude_code_log/image_export.py index 1113ac9a..089e2d58 100644 --- a/claude_code_log/image_export.py +++ b/claude_code_log/image_export.py @@ -13,6 +13,47 @@ from .models import ImageContent +# Image media types we are willing to emit into a data: URL or write to +# disk. Deliberately excludes ``image/svg+xml`` — SVG can carry inline +# ``') + assert export_image(hostile, mode="embedded") is None + + def test_embedded_rejects_svg(self): + """SVG is scriptable; embedded mode must not emit a data:image/svg+xml URL.""" + svg = _image("image/svg+xml") + assert export_image(svg, mode="embedded") is None + + def test_embedded_rejects_invalid_base64(self): + """Malformed base64 data is rejected rather than emitted verbatim.""" + bad = _image("image/png", data='not"base64>') + html = format_image_content(hostile) + assert "