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
32 changes: 31 additions & 1 deletion src/claude_code_transcripts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,10 +698,40 @@ def format_json(obj):
return f"<pre>{html.escape(str(obj))}</pre>"


# Tags that can execute code or navigate the page if they survive as live HTML.
# Python-Markdown passes raw HTML through verbatim, so any of these present in a
# message (outside a code fence, where the angle brackets would be escaped)
# become live elements. A real example: pasted captive-portal debug logs holding
# <script>top.self.location.href='http://.../portal?...'</script>
# hijack the transcript on load and it never renders. Markdown never emits these
# tags itself, so escaping them back to visible text leaves normal content alone.
_ACTIVE_TAG_RE = re.compile(
r"</?\s*(?:script|iframe|object|embed|applet|meta|link|base|frame|frameset|form)\b[^>]*>",
re.I,
)
# Inline event handlers (onerror=, onload=, …) and javascript: URIs execute
# without a <script> tag, e.g. <img src=x onerror=...>.
_EVENT_HANDLER_RE = re.compile(r"(<[^>]*?)\s+on\w+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", re.I)
_JS_URI_RE = re.compile(r"((?:href|src)\s*=\s*[\"']?)\s*javascript:", re.I)


def neutralize_active_html(rendered):
"""Defang page-hijacking HTML that markdown passed through from the source."""
rendered = _ACTIVE_TAG_RE.sub(lambda m: html.escape(m.group(0), quote=False), rendered)
prev = None
while prev != rendered: # loop so multiple handlers on one tag all go
prev = rendered
rendered = _EVENT_HANDLER_RE.sub(r"\1", rendered)
rendered = _JS_URI_RE.sub(r"\1", rendered)
return rendered


def render_markdown_text(text):
if not text:
return ""
return markdown.markdown(text, extensions=["fenced_code", "tables"])
return neutralize_active_html(
markdown.markdown(text, extensions=["fenced_code", "tables"])
)


def is_json_like(text):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_generate_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,37 @@ def test_render_markdown_text_empty(self):
assert render_markdown_text("") == ""
assert render_markdown_text(None) == ""

def test_render_markdown_neutralizes_script(self):
"""Raw <script> in message text must not survive as a live tag.

Markdown passes raw HTML through verbatim, so a pasted log line such as
a captive-portal redirect would otherwise hijack the page on load.
"""
result = render_markdown_text(
"log: <script>top.self.location.href='http://evil/x'</script>"
)
assert "<script>" not in result
assert "&lt;script&gt;top.self.location.href=" in result

def test_render_markdown_neutralizes_handlers_and_js_uris(self):
"""Event handlers and javascript: URIs execute without a <script> tag."""
result = render_markdown_text('<img src=x onerror="boom()">')
assert "onerror" not in result
result = render_markdown_text('<a href="javascript:boom()">x</a>')
assert "javascript:" not in result

def test_render_markdown_preserves_normal_content(self):
"""Sanitization must leave ordinary markdown untouched."""
result = render_markdown_text(
"# Title\n\n**bold** and `code` and a [link](http://ok).\n\n```\n<script>in a fence</script>\n```"
)
assert "<strong>bold</strong>" in result
assert "<code>code</code>" in result
assert 'href="http://ok"' in result
# Inside a fence markdown already escapes it; must stay visible text.
assert "<script>in a fence" not in result
assert "&lt;script&gt;in a fence" in result

def test_format_json(self, snapshot_html):
"""Test JSON formatting."""
result = format_json({"key": "value", "number": 42, "nested": {"a": 1}})
Expand Down