From 0c7d974ae2e4205cd474bdb905e89669197df1af Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 9 Sep 2026 17:48:12 +0800 Subject: [PATCH] fix(web): escape HTML in chat markdown rendering Prevent self-XSS when chat bubbles render user/LLM markdown via dangerouslySetInnerHTML by escaping raw HTML after math/code extraction and sanitizing link href schemes. Co-authored-by: Cursor --- web/src/engine/renderMarkdown.test.ts | 55 ++++++++++++++---- web/src/engine/renderMarkdown.ts | 84 +++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 16 deletions(-) diff --git a/web/src/engine/renderMarkdown.test.ts b/web/src/engine/renderMarkdown.test.ts index e5e89e25..267029d2 100644 --- a/web/src/engine/renderMarkdown.test.ts +++ b/web/src/engine/renderMarkdown.test.ts @@ -3,14 +3,33 @@ import assert from 'node:assert/strict'; import { renderInlineMarkdown } from './renderMarkdown'; import { sanitizeMermaidCode } from '../components/doc/blocks/MermaidBlockView'; -test('renderInlineMarkdown: renders bold, italic, strikethrough, underline', () => { - const input = '**粗体指标** *斜体说明* ~~废弃版本~~ 重要条款'; +test('renderInlineMarkdown: renders bold, italic, strikethrough', () => { + const input = '**粗体指标** *斜体说明* ~~废弃版本~~'; const html = renderInlineMarkdown(input); assert.ok(html.includes('粗体指标')); assert.ok(html.includes('斜体说明')); assert.ok(html.includes('废弃版本')); - assert.ok(html.includes('重要条款')); +}); + +test('renderInlineMarkdown: escapes raw HTML tags to prevent XSS', () => { + const scriptHtml = renderInlineMarkdown(''); + assert.ok(!scriptHtml.includes(''); + assert.ok(!/]/i.test(imgHtml), 'must not emit executable img tag'); + assert.ok(imgHtml.includes('<img')); + + const underlineHtml = renderInlineMarkdown('重要条款'); + assert.ok(!underlineHtml.includes('')); + assert.ok(underlineHtml.includes('<u>')); +}); + +test('renderInlineMarkdown: blocks javascript: link hrefs', () => { + const html = renderInlineMarkdown('[click me](javascript:alert(1))'); + assert.ok(!html.includes('javascript:')); + assert.ok(html.includes('href="#"')); }); test('renderInlineMarkdown: renders inline code and preserved line breaks', () => { @@ -22,14 +41,30 @@ test('renderInlineMarkdown: renders inline code and preserved line breaks', () = assert.ok(html.includes('
')); }); -test('renderInlineMarkdown: renders math without markdown interference', () => { - const input = '信噪比判据:$SNR = \\frac{S_{emission}}{\\sigma_{dark}} \\ge 18.5$ 符合指标'; - const html = renderInlineMarkdown(input); +test('renderInlineMarkdown: renders headings lists and tables for chat replies', () => { + const input = `# 标题一 +## 小节 - assert.ok(html.includes('katex')); - assert.ok(html.includes('SNR')); - // 确保公式内部的下划线没有被误解析为斜体 - assert.ok(!html.includes('dark')); +- 项目 A +- 项目 B + +| 参数 | 值 | +| --- | --- | +| 频率 | 24kHz | + +--- + +**结论**:可行。`; + const html = renderInlineMarkdown(input, { preserveNewlines: true }); + + assert.ok(html.includes('结论')); }); test('sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators', () => { diff --git a/web/src/engine/renderMarkdown.ts b/web/src/engine/renderMarkdown.ts index 5ae8e82b..47c7e705 100644 --- a/web/src/engine/renderMarkdown.ts +++ b/web/src/engine/renderMarkdown.ts @@ -74,6 +74,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt return `${codePlaceholderPrefix}${idx}XENDX`; }); + // 2.5 转义剩余文本中的 HTML,防止用户/LLM 输入经 dangerouslySetInnerHTML 执行脚本 + text = escapeHtml(text); + // 3. 粗斜体 ***text*** text = text.replace(/\*\*\*([^\*\n]+?)\*\*\*/g, '$1'); @@ -89,7 +92,11 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt text = text.replace(/~~([^~\n]+?)~~/g, '$1'); // 7. 链接 [text](url) - text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1'); + text = text.replace( + /\[([^\]\n]+)\]\(([^)\s]+)\)/g, + (_, label, url) => + `${label}` + ); // 8. 还原行内代码占位符 codePlaceholders.forEach((codeHtml, idx) => { @@ -101,18 +108,76 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt text = text.replace(`${placeholderPrefix}${idx}XENDX`, () => mathHtml); }); - // 10. 处理有序列表、无序列表与换行 + // 10. 处理标题 / 分隔线 / 表格 / 引用 / 有序无序列表与换行 if (options.preserveNewlines !== false) { const rawLines = text.split('\n'); const resultParts: string[] = []; let i = 0; + const isBlockHtml = (s: string) => + /<(ol|ul|h[1-6]|table|hr|blockquote)\b/i.test(s) || s.startsWith('\s?(.*)$/); + const tableMatch = line.match(/^\s*\|.+\|\s*$/); const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); const unorderedMatch = line.match(/^(\s*)[-*+]\s+(.*)$/); - if (orderedMatch) { + if (headingMatch) { + const level = Math.min(headingMatch[1].length, 6); + const sizes = ['1.35em', '1.2em', '1.1em', '1.05em', '1em', '0.95em']; + resultParts.push( + `${headingMatch[2]}` + ); + i++; + } else if (hrMatch) { + resultParts.push( + `
` + ); + i++; + } else if (tableMatch) { + const tableLines: string[] = []; + while (i < rawLines.length && /^\s*\|.+\|\s*$/.test(rawLines[i])) { + tableLines.push(rawLines[i].trim()); + i++; + } + const parsedRows: string[][] = []; + for (const tl of tableLines) { + if (/^\|?\s*[:\-| ]+\s*\|?$/.test(tl)) continue; + const cells = tl + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); + if (cells.length > 0) parsedRows.push(cells); + } + if (parsedRows.length > 0) { + const head = parsedRows[0]; + const body = parsedRows.slice(1); + const thead = `${head.map((c) => `${c}`).join('')}`; + const tbody = + body.length > 0 + ? `${body.map((r) => `${r.map((c) => `${c}`).join('')}`).join('')}` + : ''; + resultParts.push( + `
${thead}${tbody}
` + ); + } + } else if (quoteMatch) { + const quotes: string[] = []; + while (i < rawLines.length) { + const qm = rawLines[i].match(/^>\s?(.*)$/); + if (!qm) break; + quotes.push(qm[1] || ''); + i++; + } + resultParts.push( + `
${quotes.join('
')}
` + ); + } else if (orderedMatch) { const listItems: string[] = []; const startNum = parseInt(orderedMatch[2], 10) || 1; while (i < rawLines.length) { @@ -150,9 +215,7 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt const cur = resultParts[p]; if (p > 0) { const prev = resultParts[p - 1]; - const isPrevBlock = prev.startsWith('