Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion claude_code_log/html/assistant_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<img src="{src}" alt="image" class="uploaded-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'<img src="{escape_html(src)}" alt="image" class="uploaded-image" />'


def format_unknown_content(content: UnknownMessage) -> str:
Expand Down
5 changes: 4 additions & 1 deletion claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,10 @@ def _format_image(self, image: ImageContent) -> str:
)
if src is None:
return "[Image]"
return f'<img src="{src}" alt="image" class="uploaded-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'<img src="{escape_html(src)}" alt="image" class="uploaded-image" />'

# -------------------------------------------------------------------------
# System Content Formatters
Expand Down
21 changes: 4 additions & 17 deletions claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
HTML for display in transcripts.
"""

import base64
import binascii
import json
import re
from collections.abc import Iterable
Expand All @@ -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,
Expand Down Expand Up @@ -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'<img src="{escape_html(data_url)}" alt="Tool result image" '
Expand Down
63 changes: 61 additions & 2 deletions claude_code_log/image_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,49 @@
from .models import ImageContent


# Image media types we are willing to emit into a data: URL or write to
# disk. Deliberately excludes ``image/svg+xml`` — SVG can carry inline
# ``<script>`` 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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
12 changes: 11 additions & 1 deletion claude_code_log/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
}
)

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions dev-docs/application_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>` 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).
Expand Down
10 changes: 7 additions & 3 deletions dev-docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
)
```

Expand All @@ -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 `<img src>` (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)
Expand Down Expand Up @@ -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 (`<img src>` 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"><script>…` otherwise closes the attribute (#277) |

Notes:

Expand Down
104 changes: 104 additions & 0 deletions test/test_image_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,110 @@ def test_unsupported_mode_returns_none(self, sample_image: ImageContent):
assert result is None


# Minimal valid PNG (1x1 transparent pixel), shared by the security tests.
_VALID_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAA"
"BJRU5ErkJggg=="
)


def _image(media_type: str, data: str = _VALID_PNG_B64) -> ImageContent:
return ImageContent(
type="image",
source=ImageSource(type="base64", media_type=media_type, data=data),
)


class TestExportImageSecurity:
"""Guards against the embedded/referenced-image XSS vector (issue #277).

``media_type`` and ``data`` are unvalidated strings parsed from
transcript JSON, and can carry content that did not originate from
the user (tool/MCP-returned images, fetched web content). Without
an allowlist + base64 validation, a crafted media type could break
out of the ``<img src>`` attribute.
"""

def test_embedded_rejects_attribute_breakout_media_type(self):
"""A media type crafted to break out of the src attribute yields no data URL."""
hostile = _image('png"><script>alert(document.domain)</script>')
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><script>')
assert export_image(bad, mode="embedded") is None

def test_embedded_allows_valid_allowlisted_types(self):
"""The four allowlisted types with valid base64 still produce data URLs."""
for mt in ("image/png", "image/jpeg", "image/gif", "image/webp"):
result = export_image(_image(mt), mode="embedded")
assert result is not None, mt
assert result.startswith(f"data:{mt};base64,")

def test_referenced_rejects_svg(self, tmp_path: Path):
"""Referenced mode must not write a scriptable SVG to disk and link it."""
svg = _image("image/svg+xml")
result = export_image(svg, mode="referenced", output_dir=tmp_path)
assert result is None
assert not (tmp_path / "images").exists() or not list(
(tmp_path / "images").iterdir()
)

def test_referenced_rejects_invalid_base64(self, tmp_path: Path):
"""Referenced mode rejects malformed base64 instead of writing garbage."""
bad = _image("image/png", data="!!!not-base64!!!")
result = export_image(bad, mode="referenced", output_dir=tmp_path)
assert result is None

def test_html_sink_escapes_hostile_media_type(self):
"""End-to-end: the HTML <img> sink never emits an unescaped breakout.

Even if a future change let a hostile media type through
export_image, the HTML formatter must escape the src so no
``">`` can close the attribute/tag. Here the hostile type is
rejected upstream (rendering a placeholder), but we also assert
no live ``<script>`` or attribute-closing ``">`` leaks.
"""
from claude_code_log.html import format_image_content

hostile = _image('png"><script>alert(1)</script>')
html = format_image_content(hostile)
assert "<script>" not in html
assert '"><script' not in html

_BREAKOUT_SRC = 'data:image/png;base64,AAAA"><script>alert(1)</script>'

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 "<script>" not in html
assert 'src="data:image/png;base64,AAAA&quot;&gt;&lt;script&gt;' in html

def test_renderer_sink_escapes_src(self, monkeypatch: pytest.MonkeyPatch):
"""Same for the renderer's export-mode-aware sink."""
import claude_code_log.image_export as image_export
from claude_code_log.html.renderer import HtmlRenderer

monkeypatch.setattr(
image_export, "export_image", lambda *a, **k: self._BREAKOUT_SRC
)
html = HtmlRenderer()._format_image(_image("image/png"))
assert "<script>" not in html
assert 'src="data:image/png;base64,AAAA&quot;&gt;&lt;script&gt;' in html


class TestReferencedModeAcrossRenderPasses:
"""Regression: the combined and per-session passes share one images/ dir.

Expand Down
4 changes: 2 additions & 2 deletions test/test_image_paste_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,8 @@ def _entry(
) -> dict[str, object]:
if content is None:
content = [
self._block(f"ZZFIRSTZZ{_PNG_1X1}"),
self._block(f"ZZSECONDZZ{_PNG_1X1}"),
self._block(f"ZZFIRSTZZZZZ{_PNG_1X1}"),
self._block(f"ZZSECONDZZZZ{_PNG_1X1}"),
{"type": "text", "text": "look at [Image #2]"},
]
return {
Expand Down
Loading
Loading