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
&lt; ✅
- Bold, inline code, tables → byte-for-byte identical to unpatched output ✅
Tom & Jerry → &, 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.
Summary
render_markdown_text()passes message text throughmarkdown.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:
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--gistpublishing path.Cause
src/claude_code_transcripts/__init__.py:Everything else in the codebase escapes correctly —
html.escape()throughout,autoescape=True, and the|safefilters 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:
This preserves fenced code blocks correctly —
fenced_code_blockruns as a preprocessor ahead ofhtml_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 ✅&lt;✅Tom & Jerry→&, not double-escaped ✅Regenerating my real 1 MB session transcript with the patch: raw
<style>count inindex.htmldrops from 2 to 1 (only the tool's own tag frombase.html), and the previously-invisible content renders.