From 68c991fbddac21420eec45361aefcefdfae8a15d Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 15 Mar 2026 02:34:03 -0700 Subject: [PATCH] fix: restore safe HTML element rendering and suppress plantuml warning (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: restore safe HTML element rendering and suppress plantuml warning - Add safe HTML tag allowlist in inlineMarkdown: br, hr, kbd, mark, sub, sup, ins, wbr, details, summary, abbr, small, u, s (attribute-free only — XSS safe, tags with attributes stay escaped) - Add
/ block-level protection pass with recursive markdown rendering of inner content - Add plantuml to _NO_HIGHLIGHT_LANGS (suppresses highlight.js warning for unsupported language) - CSS for details (collapsible, overflow hidden), kbd (mono font, key style), mark (yellow-glow token for theme adaptation) * fix: restrict safe tags to inline-only, broaden details regex - Remove hr, details, summary from inline _SAFE_TAGS allowlist (they are block-level and produce invalid HTML inside

wrappers) - Make

regex newline-optional so same-line
Title patterns are captured --- turnstone/ui/static/renderer.js | 40 +++++++++++++++++++++++++++++++++ turnstone/ui/static/style.css | 29 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/turnstone/ui/static/renderer.js b/turnstone/ui/static/renderer.js index 1928def6..81063c67 100644 --- a/turnstone/ui/static/renderer.js +++ b/turnstone/ui/static/renderer.js @@ -3,9 +3,18 @@ // --------------------------------------------------------------------------- // Inline formatting // --------------------------------------------------------------------------- +// Safe inline HTML tags allowed through escapeHtml (no attributes — XSS safe) +// Block-level tags (details, summary, hr) handled by their own protection passes +var _SAFE_TAGS = + /<(\/?(?:br|kbd|mark|sub|sup|ins|wbr|abbr|small|u|s))(?:\s*\/?)>/gi; + function inlineMarkdown(text) { // Escape HTML first so only tags we generate are real text = escapeHtml(text); + // Restore safe HTML tags (attribute-free only — already escaped so no XSS) + text = text.replace(_SAFE_TAGS, function (m, tag) { + return "<" + tag + ">"; + }); // Bold (asterisks only — underscores cause false positives on snake_case) text = text.replace(/\*\*(.+?)\*\*/g, "$1"); // Italic (asterisks only) @@ -304,6 +313,30 @@ function renderMarkdown(text) { return "\x00CB" + (codeBlocks.length - 1) + "\x00"; }); + // Protect
blocks (safe HTML — attribute-free only) + var detailsBlocks = []; + text = text.replace( + /
\s*\n?([\s\S]*?)<\/details>/gi, + function (m, inner) { + var sumMatch = inner.match( + /^\s*([\s\S]*?)<\/summary>\s*\n?([\s\S]*)/i, + ); + var html; + if (sumMatch) { + html = + "
" + + inlineMarkdown(sumMatch[1].trim()) + + "" + + renderMarkdown(sumMatch[2]) + + "
"; + } else { + html = "
" + renderMarkdown(inner) + "
"; + } + detailsBlocks.push(html); + return "\x00DT" + (detailsBlocks.length - 1) + "\x00"; + }, + ); + // Protect display math ($$...$$) — must come before inline code/math var mathBlocks = []; text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) { @@ -579,6 +612,12 @@ function renderMarkdown(text) { result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) { return codeBlocks[parseInt(idx)]; }); + result = result.replace(/

\x00DT(\d+)\x00<\/p>/g, function (m, idx) { + return detailsBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00DT(\d+)\x00/g, function (m, idx) { + return detailsBlocks[parseInt(idx)]; + }); result = result.replace(/

\x00BQ(\d+)\x00<\/p>/g, function (m, idx) { return bqBlocks[parseInt(idx)]; }); @@ -618,6 +657,7 @@ var _NO_HIGHLIGHT_LANGS = { plain: true, nohighlight: true, mermaid: true, + plantuml: true, }; var _TERMINAL_LANGS = { bash: true, diff --git a/turnstone/ui/static/style.css b/turnstone/ui/static/style.css index 06106e13..0166a551 100644 --- a/turnstone/ui/static/style.css +++ b/turnstone/ui/static/style.css @@ -358,6 +358,35 @@ body { position: static; } .msg-assistant .mermaid-container svg * { animation: none !important; } } +/* Safe HTML elements */ +.msg-assistant details { + margin: 8px 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-surface); + overflow: hidden; +} +.msg-assistant details summary { + padding: 8px 12px; + cursor: pointer; + font-weight: 600; + color: var(--fg-bright); + user-select: none; +} +.msg-assistant details summary:hover { color: var(--accent); } +.msg-assistant details[open] > summary { border-bottom: 1px solid var(--border); } +.msg-assistant details > :not(summary) { padding: 0 12px; } +.msg-assistant kbd { + background: var(--bg-highlight); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 1px 5px; + font-size: 11px; + font-family: var(--font-mono); + box-shadow: 0 1px 0 var(--border-strong); +} +.msg-assistant mark { background: var(--yellow-glow); color: var(--fg-bright); border-radius: 2px; padding: 0 2px; } + /* GFM Callouts / Alerts */ .msg-assistant .callout { border-left: 3px solid var(--border-strong);