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:
- allowlist
media_type (png/jpeg/gif/webp),
base64.b64decode(validate=True) the data,
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.
Summary
The embedded-image rendering path interpolates the image's
media_typeand base64datastraight 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 craftedmedia_typecan break out of thesrcattribute and inject a live<script>into the generated HTML — stored XSS that executes when the file is opened in a browser underfile://(local-file read / exfiltration).Affected code
claude_code_log/image_export.py:43builds the data URL:claude_code_log/html/assistant_formatters.py:111claude_code_log/html/renderer.py:476ImageSource.media_typeand.dataare plain unvalidatedstr(claude_code_log/models.py:110-111), parsed directly from JSON.embeddedis the default HTML image mode, so this is the normal path.Reproduction
A user-turn image content block with:
renders as:
The
">closes thesrcattribute and the<img>tag; the<script>then executes on open. A"indatawould 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:media_typeto{image/png, image/jpeg, image/gif, image/webp}(excludes scriptableimage/svg+xml),base64.b64decode(data, validate=True),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:media_type(png/jpeg/gif/webp),base64.b64decode(validate=True)thedata,escape_html(...)the finalsrc.Add a regression test for the
media_type = 'png"><script>...'breakout and for thedataquote case.Severity
Stored XSS in a
file://context against a page rendered from the user's private logs. High.