// Safety check for the comment renderer: comments are stored as plain text and // turned into HTML on read, so this asserts that nothing a user types can come // back out as executable markup, and that math and links still work. // // Run with: npx tsx scripts/check-comment-render.ts import { renderCommentHtml } from "../src/lib/comment-render"; // Tags that can execute or exfiltrate. KaTeX legitimately emits svg/path/MathML, // so the audit targets these specifically rather than allow-listing every tag // KaTeX might produce (and then false-failing on a tag we forgot). const FORBIDDEN_TAGS = /^(script|iframe|object|embed|form|input|link|meta|base|style)$/i; /// Audits only REAL tags. Escaped text like `<img onerror=...>` is inert /// and correctly ignored - an earlier version of this check matched the literal /// substring anywhere and produced false alarms on properly escaped content. function auditTags(html: string): string[] { const problems: string[] = []; for (const m of html.matchAll(/<\/?([a-zA-Z][\w:-]*)([^>]*)>/g)) { const name = m[1]; const attrs = m[2] ?? ""; if (FORBIDDEN_TAGS.test(name)) problems.push(`executable tag <${name}>`); if (/\son\w+\s*=/i.test(attrs)) problems.push(`event handler on <${name}>: ${attrs.trim()}`); if (/(href|src|xlink:href)\s*=\s*["']?\s*javascript:/i.test(attrs)) { problems.push(`javascript: URL on <${name}>`); } } return problems; } const attacks: [string, string][] = [ ["script tag", ``], ["img onerror", ``], ["anchor with javascript:", `click`], ["attribute breakout", `" onmouseover="alert(1)`], ["svg onload", ``], ["katex \\href (trust:false)", `$\\href{javascript:alert(1)}{x}$`], ["bare javascript: url", `javascript:alert(1)`], ["html entity double-encode", `<script>alert(1)</script>`], ["markup inside math", `$$`], ]; const features: [string, string, RegExp][] = [ ["inline math", `Euler: $e^{i\\pi}+1=0$`, /katex/], ["display math", `$$\\sum_{n=1}^{\\infty}\\frac{1}{n^2}$$`, /katex-display/], ["link", `See https://arxiv.org/abs/2501.00001 now`, /\./], ["query-string ampersand escaped", `https://example.com/a?x=1&y=2`, /x=1&y=2/], ["paragraphs", `one\n\ntwo`, /

one<\/p>

two<\/p>/], ["line break", `one\ntwo`, /one
two/], ]; let failed = 0; for (const [name, input] of attacks) { const out = renderCommentHtml(input); const problems = auditTags(out); // Anything the user wrote with a '<' must come back escaped. if (input.includes("<") && !out.includes("<")) { problems.push("angle bracket was not escaped"); } if (problems.length > 0) { console.error(`FAIL ${name}\n ${problems.join("; ")}\n ${out}`); failed += 1; } else { console.log(`ok ${name} (neutralised)`); } } for (const [name, input, expect] of features) { const out = renderCommentHtml(input); if (expect.test(out)) { console.log(`ok ${name}`); } else { console.error(`FAIL ${name} did not match ${expect}\n ${out}`); failed += 1; } } if (failed > 0) { console.error(`\n${failed} check(s) failed`); process.exit(1); } console.log("\nAll comment-render checks passed.");