Skip to content

Escape and allowlist embedded image data URLs to prevent stored XSS#280

Open
evoskamp wants to merge 1 commit into
daaain:mainfrom
TheVoskamps:fix/image-xss-277
Open

Escape and allowlist embedded image data URLs to prevent stored XSS#280
evoskamp wants to merge 1 commit into
daaain:mainfrom
TheVoskamps:fix/image-xss-277

Conversation

@evoskamp

@evoskamp evoskamp commented Jul 14, 2026

Copy link
Copy Markdown

What & why

The embedded-image rendering path interpolated an image's media_type and base64 data straight into an <img src="..."> attribute with no HTML-escaping, media-type allowlist, or base64 validation. Those fields come from transcript JSON and can carry content that did not originate from the user (images returned by tools/MCP servers, fetched from web pages, IDE/file attachments), so a crafted media_type breaks out of the attribute and injects a live <script> — stored XSS that runs when the generated HTML is opened in a browser under file:// (local-file read / exfiltration).

embedded is the default HTML image mode, so this is the normal path.

Repro (before this PR)

A user-turn image block with media_type = png"><script>alert(document.domain)</script> rendered as:

<img src="data:png"><script>alert(document.domain)</script>;base64,AAAA" ... >

The "> closes the src attribute and the <img> tag; the <script> executes on open. A " in data does the same.

The fix

  • New shared _is_safe_image_source() guard in image_export.py: allowlists media_type to image/{png,jpeg,gif,webp} (excludes scriptable image/svg+xml) and validates the base64 with base64.b64decode(validate=True). Applied to both embedded and referenced modes; image/svg+xml dropped from _get_extension.
  • Both HTML <img> sinks (html/assistant_formatters.py, html/renderer.py) now wrap src in escape_html.
  • This mirrors the guards already present on the tool-result image path (html/tool_formatters.py), which allowlisted, validated, and escaped — the user/assistant path had simply missed all three.

The Markdown output path (![](data:...)) intentionally keeps the raw data URL (escaping would be wrong there) but still gains the upstream allowlist + base64 validation.

Tests

Adds regression coverage in test/test_image_export.py:

  • attribute-breakout media_type rejected (embedded),
  • SVG rejected (embedded and referenced),
  • invalid base64 rejected (embedded and referenced),
  • allowlisted types still produce data URLs,
  • end-to-end: the HTML <img> sink emits no "><script breakout.

Full unit suite green; pyright clean on changed files.

Closes #277

Summary by CodeRabbit

  • Security
    • Improved image handling to prevent unsafe content from breaking HTML attributes or executing scripts.
    • Restricted supported image formats to PNG, JPEG, GIF, and WEBP.
    • Invalid or malformed image data now displays a placeholder instead of being embedded or saved.
  • Bug Fixes
    • Added HTML escaping for image sources in rendered output.
    • Prevented unsafe image files from being written to disk.

The embedded-image path interpolated an image's media_type and base64
data straight into an <img src="..."> 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"><script>...</script>` 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 <img> 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: daaain#277

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q2uhgREMMmpYR6AMH65whA
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd6cb05a-9525-44d8-b7f2-e17e9b47fc71

📥 Commits

Reviewing files that changed from the base of the PR and between d2a1b66 and e7b8364.

📒 Files selected for processing (4)
  • claude_code_log/html/assistant_formatters.py
  • claude_code_log/html/renderer.py
  • claude_code_log/image_export.py
  • test/test_image_export.py

📝 Walkthrough

Walkthrough

Image export now restricts media types and validates base64 data for embedded and referenced images. HTML formatters escape generated image sources before rendering <img> attributes, with tests covering unsafe media types, malformed data, and valid formats.

Changes

Image security hardening

Layer / File(s) Summary
Validate image sources before export
claude_code_log/image_export.py, test/test_image_export.py
Embedded and referenced exports reject non-allowlisted media types and invalid base64 data; tests cover rejected inputs, valid allowlisted formats, and prevented file creation.
Escape rendered image sources
claude_code_log/html/assistant_formatters.py, claude_code_log/html/renderer.py
HTML image sources are escaped before insertion into <img> attributes, with regression coverage for attribute-breakout payloads.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: cboos

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main security hardening around embedded image data URLs and stored XSS.
Linked Issues check ✅ Passed The changes match #277 by validating base64, allowlisting image types, rejecting SVG, and escaping HTML image sources.
Out of Scope Changes check ✅ Passed The code and tests stay focused on the linked image XSS fix with no unrelated feature work apparent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@evoskamp
evoskamp marked this pull request as ready for review July 15, 2026 01:51
@cboos

cboos commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Thanks! Looks good & useful. Would you mind considering making _is_safe_image_source "public" so that it would be usable in plugins? And as such, documented in dev-docs/plugins.md (4.2).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stored XSS: unescaped image media_type/data in embedded <img src> (user/assistant path)

2 participants