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 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'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/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'Tool result image`` 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 `![](target)` 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">