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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = '**粗体指标** *斜体说明* ~~废弃版本~~ <u>重要条款</u>';
|
||||
test('renderInlineMarkdown: renders bold, italic, strikethrough', () => {
|
||||
const input = '**粗体指标** *斜体说明* ~~废弃版本~~';
|
||||
const html = renderInlineMarkdown(input);
|
||||
|
||||
assert.ok(html.includes('<strong>粗体指标</strong>'));
|
||||
assert.ok(html.includes('<em>斜体说明</em>'));
|
||||
assert.ok(html.includes('<del style="color:var(--text-muted)">废弃版本</del>'));
|
||||
assert.ok(html.includes('<u>重要条款</u>'));
|
||||
});
|
||||
|
||||
test('renderInlineMarkdown: escapes raw HTML tags to prevent XSS', () => {
|
||||
const scriptHtml = renderInlineMarkdown('<script>alert(1)</script>');
|
||||
assert.ok(!scriptHtml.includes('<script'));
|
||||
assert.ok(scriptHtml.includes('<script>'));
|
||||
|
||||
const imgHtml = renderInlineMarkdown('<img src=x onerror=alert(1)>');
|
||||
assert.ok(!/<img[\s>]/i.test(imgHtml), 'must not emit executable img tag');
|
||||
assert.ok(imgHtml.includes('<img'));
|
||||
|
||||
const underlineHtml = renderInlineMarkdown('<u>重要条款</u>');
|
||||
assert.ok(!underlineHtml.includes('<u>'));
|
||||
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('<br/>'));
|
||||
});
|
||||
|
||||
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'));
|
||||
// 确保公式内部的下划线没有被误解析为斜体 <em>
|
||||
assert.ok(!html.includes('<em>dark</em>'));
|
||||
- 项目 A
|
||||
- 项目 B
|
||||
|
||||
| 参数 | 值 |
|
||||
| --- | --- |
|
||||
| 频率 | 24kHz |
|
||||
|
||||
---
|
||||
|
||||
**结论**:可行。`;
|
||||
const html = renderInlineMarkdown(input, { preserveNewlines: true });
|
||||
|
||||
assert.ok(html.includes('<h1'));
|
||||
assert.ok(html.includes('标题一'));
|
||||
assert.ok(html.includes('<h2'));
|
||||
assert.ok(html.includes('<ul'));
|
||||
assert.ok(html.includes('<table'));
|
||||
assert.ok(html.includes('24kHz'));
|
||||
assert.ok(html.includes('<hr'));
|
||||
assert.ok(html.includes('<strong>结论</strong>'));
|
||||
});
|
||||
|
||||
test('sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators', () => {
|
||||
|
||||
@@ -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, '<strong><em>$1</em></strong>');
|
||||
|
||||
@@ -89,7 +92,11 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
|
||||
text = text.replace(/~~([^~\n]+?)~~/g, '<del style="color:var(--text-muted)">$1</del>');
|
||||
|
||||
// 7. 链接 [text](url)
|
||||
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:var(--brand-500);text-decoration:underline">$1</a>');
|
||||
text = text.replace(
|
||||
/\[([^\]\n]+)\]\(([^)\s]+)\)/g,
|
||||
(_, label, url) =>
|
||||
`<a href="${sanitizeHref(url)}" target="_blank" rel="noopener noreferrer" style="color:var(--brand-500);text-decoration:underline">${label}</a>`
|
||||
);
|
||||
|
||||
// 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('<ol') || s.startsWith('<ul');
|
||||
|
||||
while (i < rawLines.length) {
|
||||
const line = rawLines[i];
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
const hrMatch = line.match(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/);
|
||||
const quoteMatch = line.match(/^>\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(
|
||||
`<h${level} class="chat-md-h" style="margin:10px 0 6px;font-size:${sizes[level - 1]};font-weight:700;line-height:1.35;color:inherit;">${headingMatch[2]}</h${level}>`
|
||||
);
|
||||
i++;
|
||||
} else if (hrMatch) {
|
||||
resultParts.push(
|
||||
`<hr class="chat-md-hr" style="border:none;border-top:1px solid var(--border);margin:10px 0;opacity:0.85;" />`
|
||||
);
|
||||
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 = `<thead><tr>${head.map((c) => `<th style="border:1px solid var(--border);padding:6px 8px;background:var(--bg-subtle);text-align:left;font-weight:600;">${c}</th>`).join('')}</tr></thead>`;
|
||||
const tbody =
|
||||
body.length > 0
|
||||
? `<tbody>${body.map((r) => `<tr>${r.map((c) => `<td style="border:1px solid var(--border);padding:6px 8px;vertical-align:top;">${c}</td>`).join('')}</tr>`).join('')}</tbody>`
|
||||
: '';
|
||||
resultParts.push(
|
||||
`<div style="overflow-x:auto;margin:8px 0;"><table class="chat-md-table" style="border-collapse:collapse;width:100%;font-size:12.5px;line-height:1.45;">${thead}${tbody}</table></div>`
|
||||
);
|
||||
}
|
||||
} 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(
|
||||
`<blockquote class="chat-md-quote" style="margin:8px 0;padding:6px 10px;border-left:3px solid var(--brand-400);background:var(--bg-subtle);border-radius:0 6px 6px 0;">${quotes.join('<br/>')}</blockquote>`
|
||||
);
|
||||
} 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('<ol') || prev.startsWith('<ul');
|
||||
const isCurBlock = cur.startsWith('<ol') || cur.startsWith('<ul');
|
||||
if (!isPrevBlock && !isCurBlock) {
|
||||
if (!isBlockHtml(prev) && !isBlockHtml(cur)) {
|
||||
outHtml += '<br/>';
|
||||
}
|
||||
}
|
||||
@@ -172,3 +235,12 @@ function escapeHtml(str: string): string {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Block executable URL schemes in markdown link hrefs. */
|
||||
function sanitizeHref(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (/^(javascript|data|vbscript):/i.test(trimmed)) {
|
||||
return '#';
|
||||
}
|
||||
return escapeHtml(trimmed);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user