Skip to content
Open
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 @@ -473,7 +473,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
61 changes: 59 additions & 2 deletions claude_code_log/image_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,47 @@
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://``. Mirrors the allowlist already enforced on the
# tool-result image path in ``html/tool_formatters.py`` (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 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 @@ -40,12 +81,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

try:
# Create images subdirectory
images_dir = output_dir / "images"
Expand All @@ -71,12 +123,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")
78 changes: 78 additions & 0 deletions test/test_image_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,81 @@ def test_unsupported_mode_returns_none(self, sample_image: ImageContent):
"""Unsupported mode returns None."""
result = export_image(sample_image, mode="unknown_mode")
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, counter=1)
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, counter=1)
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
Loading