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'
'
+ # 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'
'
def format_unknown_content(content: UnknownMessage) -> str:
diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py
index 8b33a7ea..e9901f6c 100644
--- a/claude_code_log/html/renderer.py
+++ b/claude_code_log/html/renderer.py
@@ -533,7 +533,10 @@ def _format_image(self, image: ImageContent) -> str:
)
if src is None:
return "[Image]"
- return f'
'
+ # 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'
'
# -------------------------------------------------------------------------
# System Content Formatters
diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py
index 08e35553..e39009e9 100644
--- a/claude_code_log/html/tool_formatters.py
+++ b/claude_code_log/html/tool_formatters.py
@@ -14,8 +14,6 @@
HTML for display in transcripts.
"""
-import base64
-import binascii
import json
import re
from collections.abc import Iterable
@@ -37,6 +35,7 @@
render_user_markdown_collapsible,
resolve_memory_body_links,
)
+from ..image_export import is_safe_image_source
from ..json_depth import exceeds_depth
from ..utils import (
ARTIFACT_FAVICON_TEXT_MAX,
@@ -1600,22 +1599,10 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
source = cast(dict[str, Any], item.get("source", {}))
if source:
media_type: str = str(source.get("media_type", "image/png"))
- # Restrict to safe image types to prevent XSS via SVG
- allowed_media_types = {
- "image/png",
- "image/jpeg",
- "image/gif",
- "image/webp",
- }
- if media_type not in allowed_media_types:
- continue
data: str = str(source.get("data", ""))
- if data:
- # Validate base64 data to prevent corruption/injection
- try:
- base64.b64decode(data, validate=True)
- except (binascii.Error, ValueError):
- continue
+ # Same gate as user/assistant images: allowlisted
+ # (non-SVG) type and strictly-valid base64.
+ if data and is_safe_image_source(media_type, data):
data_url = f"data:{media_type};base64,{data}"
image_html_parts.append(
f'
`` and event handlers, so a data:image/svg+xml URL is a
+# scriptable XSS vector when the generated page is opened under
+# ``file://``. Shared by every image sink: user/assistant images here,
+# tool-result images in ``html/tool_formatters.py``, and plugins via
+# ``claude_code_log.plugins.is_safe_image_source`` (issue #277).
+ALLOWED_IMAGE_MEDIA_TYPES = frozenset(
+ {
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ }
+)
+
+
+def is_safe_image_source(media_type: str, data: str) -> bool:
+ """Whether an image's media type and base64 data are safe to emit.
+
+ Guards the embedded (data: URL) and referenced (write-to-disk)
+ paths, the tool-result image path, and plugin image sinks (it is
+ re-exported from ``claude_code_log.plugins``) against two problems:
+
+ - a non-allowlisted / scriptable media type (notably SVG), and
+ - malformed base64 (which could smuggle a raw ``"`` / ``>`` past a
+ naive interpolation, or corrupt the written file).
+
+ Returns ``True`` only when the media type is allowlisted *and* the
+ data is strictly-valid base64. Callers should fall back to a
+ placeholder (``None``) otherwise. Note this does not, on its own,
+ make the result safe to drop into HTML unescaped — the HTML sink
+ must still ``escape_html`` the final ``src`` (issue #277).
+ """
+ if media_type not in ALLOWED_IMAGE_MEDIA_TYPES:
+ return False
+ try:
+ base64.b64decode(data, validate=True)
+ except (binascii.Error, ValueError):
+ return False
+ return True
+
+
def export_image(
image: "ImageContent",
mode: str,
@@ -54,12 +97,23 @@ def export_image(
return None
if mode == "embedded":
+ # Reject scriptable / non-allowlisted media types and malformed
+ # base64 before building the data: URL. Even with these guards
+ # the HTML sink must still escape the returned src (issue #277);
+ # returning None here degrades to a placeholder.
+ if not is_safe_image_source(image.source.media_type, image.source.data):
+ return None
return f"data:{image.source.media_type};base64,{image.source.data}"
if mode == "referenced":
if output_dir is None:
return None
+ # Same allowlist/validation as embedded mode: don't write a
+ # scriptable or malformed image to disk and then reference it.
+ if not is_safe_image_source(image.source.media_type, image.source.data):
+ return None
+
tmp_path: Path | None = None
try:
image_data = base64.b64decode(image.source.data)
@@ -95,12 +149,17 @@ def export_image(
def _get_extension(media_type: str) -> str:
- """Get file extension from media type."""
+ """Get file extension from media type.
+
+ Only the allowlisted media types (``ALLOWED_IMAGE_MEDIA_TYPES``)
+ reach this in referenced mode; ``image/svg+xml`` is intentionally
+ absent because SVG is rejected upstream as a scriptable XSS vector
+ (issue #277).
+ """
ext_map = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
- "image/svg+xml": ".svg",
}
return ext_map.get(media_type, ".png")
diff --git a/claude_code_log/plugins.py b/claude_code_log/plugins.py
index bdcbac27..11b2954d 100644
--- a/claude_code_log/plugins.py
+++ b/claude_code_log/plugins.py
@@ -44,6 +44,7 @@
render_markdown,
render_markdown_collapsible,
)
+ from .image_export import is_safe_image_source
from .markdown.renderer import safe_markdown_inline, safe_markdown_link_target
from .utils import is_safe_web_url
@@ -373,6 +374,10 @@ def apply_transformers(
# that break out of the ``(target)`` slot or smuggle attributes
# through a downstream Markdown->HTML conversion.
"safe_markdown_link_target",
+ # Image gate (#277): a transcript image's ``media_type`` and base64
+ # ``data`` are untrusted; a plugin building a ``data:`` URL needs the
+ # same allowlist (no SVG) + strict base64 check as the core sinks.
+ "is_safe_image_source",
}
)
@@ -381,7 +386,8 @@ def __getattr__(name: str) -> Any: # PEP 562
if name in _PUBLIC_HELPERS:
# The ``safe_markdown_*`` pair (inline HTML-neutralising gate +
# link-target percent-encoder) lives in ``markdown/renderer.py``;
- # ``is_safe_web_url`` in top-level ``utils.py``; the others in
+ # ``is_safe_web_url`` in top-level ``utils.py``;
+ # ``is_safe_image_source`` in ``image_export.py``; the others in
# ``html/utils.py``. Resolved lazily to keep package init acyclic.
if name == "safe_markdown_inline":
from .markdown.renderer import safe_markdown_inline as resolved
@@ -390,6 +396,9 @@ def __getattr__(name: str) -> Any: # PEP 562
elif name == "is_safe_web_url":
# Format-neutral (shared by both renderers) → top-level utils.
from .utils import is_safe_web_url as resolved
+ elif name == "is_safe_image_source":
+ # Format-neutral image gate → ``image_export.py``.
+ from .image_export import is_safe_image_source as resolved
else:
from .html import utils as _utils
@@ -404,6 +413,7 @@ def __getattr__(name: str) -> Any: # PEP 562
"MessageTransformer",
"apply_transformers",
"escape_html",
+ "is_safe_image_source",
"is_safe_web_url",
"load_transformers",
"render_markdown",
diff --git a/dev-docs/application_model.md b/dev-docs/application_model.md
index b314c1f2..c5d7a4df 100644
--- a/dev-docs/application_model.md
+++ b/dev-docs/application_model.md
@@ -566,6 +566,17 @@ format-agnostic: HTML and Markdown both call into it. Three modes
- `referenced` — write the image to disk next to the output and
embed a `src=` reference.
+`embedded` and `referenced` both refuse an image unless
+`is_safe_image_source` passes: the media type must be PNG, JPEG, GIF
+or WebP (never the scriptable `image/svg+xml`) and the data strictly
+valid base64, otherwise the image degrades to the placeholder. The
+transcript's `media_type` and `data` are untrusted (an image can come
+back from a tool, an MCP server or a web page), and a crafted
+`media_type` used to break out of the `
` attribute (issue
+#277); the HTML sinks also `escape_html` the final `src`. Tool-result
+images go through the same gate, and plugins get it from
+`claude_code_log.plugins` ([plugins.md § 4.2](plugins.md#42-security-conscious-rendering)).
+
Default is `embedded` for HTML (single self-contained file) and
`referenced` for Markdown (keeps the `.md` text small and lets
images live as separate PNGs alongside).
diff --git a/dev-docs/plugins.md b/dev-docs/plugins.md
index 76851236..201ca4a4 100644
--- a/dev-docs/plugins.md
+++ b/dev-docs/plugins.md
@@ -289,11 +289,12 @@ moves to the next ancestor.
### 4.1 Plugin-facing helpers
-Five helpers are re-exported from `claude_code_log.plugins` for use
+Seven helpers are re-exported from `claude_code_log.plugins` for use
in `format_html` / `format_markdown` / `title` methods. The re-export is
the stable plugin API; the underlying implementation (in
-`claude_code_log/html/utils.py`, `claude_code_log/markdown/renderer.py`
-and `claude_code_log/utils.py`) may move or be renamed.
+`claude_code_log/html/utils.py`, `claude_code_log/markdown/renderer.py`,
+`claude_code_log/image_export.py` and `claude_code_log/utils.py`) may
+move or be renamed.
```python
from claude_code_log.plugins import (
@@ -303,6 +304,7 @@ from claude_code_log.plugins import (
safe_markdown_inline,
is_safe_web_url,
safe_markdown_link_target,
+ is_safe_image_source,
)
```
@@ -314,6 +316,7 @@ from claude_code_log.plugins import (
| `safe_markdown_inline(text)` | `(str) -> str` | Interpolating transcript-derived text into a Markdown **inline** fragment in `format_markdown` (a link label, a list item, inline prose) — the Markdown-output path emits `format_markdown` verbatim. Entity-escapes raw HTML tags while preserving Markdown formatting. |
| `is_safe_web_url(url)` | `(str) -> bool` | Gating a transcript-derived URL before making it clickable — an `href` in `format_html` or a `[text](target)` destination in `format_markdown`. Escaping cannot neutralise a hostile scheme (`javascript:alert(1)` survives `escape_html` intact), so emit the link only when this returns True and degrade to inert escaped text otherwise. http(s) whitelist, fails closed. |
| `safe_markdown_link_target(url)` | `(str) -> str` | Encoding a URL you are about to place in a Markdown `(target)` slot — a `[text](target)` link or `` image in `format_markdown`. An `is_safe_web_url`-accepted URL can still carry `"`/`'`/`<`/`>`/space/parens that break out of the destination or smuggle attributes once a downstream viewer converts the Markdown to HTML; this percent-encodes them. Use **after** the scheme gate, on the target only (label the link with `safe_markdown_inline` text). |
+| `is_safe_image_source(media_type, data)` | `(str, str) -> bool` | Gating a transcript-derived image before you build a `data:{media_type};base64,{data}` URL for an `
` (or a Markdown `` target). True only for an allowlisted type (`image/png`, `image/jpeg`, `image/gif`, `image/webp` — never the scriptable `image/svg+xml`) with strictly-valid base64; render a placeholder otherwise. The same gate the core image sinks use. It does not escape: still `escape_html` the finished URL in an HTML attribute. |
The reference plugin's
[`tool_communicate_result.py`](../test/_plugins/clmail/src/claude_code_log_clmail_test/transformers/tool_communicate_result.py)
@@ -347,6 +350,7 @@ output paths:
| `title()` return | `escape_html(value)` — the HTML header emits it via `\| safe` (no core escaping); the Markdown heading is auto-gated by the core |
| URL made clickable (`href` attribute) | gate on `is_safe_web_url(url)` first, **then** `escape_html(url)` for the attribute text — the scheme check and the escaping guard different attacks |
| URL made clickable (Markdown link/image target) | gate on `is_safe_web_url(url)` first, **then** `safe_markdown_link_target(url)` for the `(target)` slot — guards against attribute-smuggling through the downstream Markdown→HTML pass |
+| Image `data:` URL (`
` or Markdown ``) from a transcript `media_type` + base64 `data` | gate on `is_safe_image_source(media_type, data)` first (allowlist without SVG, strict base64), **then** `escape_html(data_url)` for the HTML attribute — a crafted `media_type` such as `png">')
+ 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 "'
+
+ def test_assistant_sink_escapes_src(self, monkeypatch: pytest.MonkeyPatch):
+ """The escaping layer on its own: with the gate bypassed, a quote in
+ the src still cannot close the attribute."""
+ import claude_code_log.html.assistant_formatters as formatters
+
+ monkeypatch.setattr(
+ formatters, "export_image", lambda *a, **k: self._BREAKOUT_SRC
+ )
+ html = formatters.format_image_content(_image("image/png"))
+ assert "', png)
+ assert not is_safe_image_source("IMAGE/PNG", png)
+ # Strict base64: a quote or angle bracket in the data is rejected.
+ assert not is_safe_image_source("image/png", 'AAAA">