Skip to content

Raw HTML in message text is rendered as live markup, silently truncating the transcript #105

Description

@KirkMasden

Summary

render_markdown_text() passes message text through markdown.markdown(), which by design passes raw HTML through to the output. Message text that contains literal HTML therefore becomes live markup in the generated page.

The practical consequence is worse than cosmetic: an unclosed <style> tag in a prompt causes the browser to parse the entire remainder of the page as CSS. The transcript appears to end mid-sentence, with no error. The content is present in the HTML file — it just never renders.

Reproduction

Any prompt containing literal HTML. Mine was a Claude Code instruction about editing an HTML file:

Leave the <head>, the existing <style> block, and all existing content exactly as they are.

Rendered output stops at "Leave the , the existing" — <head> is consumed as a tag, and <style> swallows everything after it. My session had ~2 further hours of content that was in the file but invisible.

A prompt containing <script> would likewise execute on page open. Local file, own content, so low practical risk — but "renders arbitrary HTML from message text" seems undesirable in a transcript viewer, particularly one with a --gist publishing path.

Cause

src/claude_code_transcripts/__init__.py:

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

Everything else in the codebase escapes correctly — html.escape() throughout, autoescape=True, and the |safe filters in the templates are all legitimate (those values genuinely are pre-rendered HTML). This one path is the gap.

Suggested fix

Deregister Python-Markdown's HTML pass-through handlers so tags render as visible text:

def render_markdown_text(text):
    if not text:
        return ""
    md = markdown.Markdown(extensions=["fenced_code", "tables"])
    md.preprocessors.deregister("html_block")
    md.inlinePatterns.deregister("html")
    return md.convert(text)

This preserves fenced code blocks correctly — fenced_code_block runs as a preprocessor ahead of html_block, so fenced content is stashed before the HTML handlers would see it. A naive escape-the-input-first approach would double-escape inside fences; this doesn't.

Verification

Tested against Python-Markdown 3.10.2:

  • Leave the <head>, the existing <style> block → escaped, visible as text ✅
  • <script>alert(1)</script> → escaped, not executed ✅
  • Fenced code block containing HTML → escaped exactly once, no &amp;lt;
  • Bold, inline code, tables → byte-for-byte identical to unpatched output ✅
  • Tom & Jerry&amp;, not double-escaped ✅

Regenerating my real 1 MB session transcript with the patch: raw <style> count in index.html drops from 2 to 1 (only the tool's own tag from base.html), and the previously-invisible content renders.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions