Skip to content

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

Description

@evoskamp

Summary

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

Affected code

  • claude_code_log/image_export.py:43 builds the data URL:
    return f"data:{image.source.media_type};base64,{image.source.data}"
  • Sinks that embed it with no escaping:
    • claude_code_log/html/assistant_formatters.py:111
    • claude_code_log/html/renderer.py:476
    return f'<img src="{src}" alt="image" class="uploaded-image" />'
  • ImageSource.media_type and .data are plain unvalidated str (claude_code_log/models.py:110-111), parsed directly from JSON.

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

Reproduction

A user-turn image content block with:

media_type = png"><script>alert(document.domain)</script>

renders as:

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

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

Why this is an oversight, not accepted risk

The tool-result image path already does the right thing at claude_code_log/html/tool_formatters.py:1479-1500:

  • allowlists media_type to {image/png, image/jpeg, image/gif, image/webp} (excludes scriptable image/svg+xml),
  • validates the base64 with base64.b64decode(data, validate=True),
  • and wraps the final URL in escape_html(data_url).

The user/assistant embedded-image path does none of the three.

Suggested fix

Centralise a hardened data-URL builder (e.g. in image_export.py) applying the same three guards as the tool-result path, and route both <img> sinks (and ideally the tool-result path) through it:

  1. allowlist media_type (png/jpeg/gif/webp),
  2. base64.b64decode(validate=True) the data,
  3. escape_html(...) the final src.

Add a regression test for the media_type = 'png"><script>...' breakout and for the data quote case.

Severity

Stored XSS in a file:// context against a page rendered from the user's private logs. High.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions