Merge pull request #241 from H-Chris233/main

security(qa): sanitize assistant markdown rendering in QA panel
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-04 18:52:05 +08:00
commit 5d15f1d2f2
3 files changed
+104 -7

No files matched your search

@@ -0,0 +1,34 @@
import { renderQaMarkdown } from './qaMarkdown';
function assertIncludes(text: string, expected: string, name: string) {
if (!text.includes(expected)) {
throw new Error(`${name}: expected to include "${expected}", got "${text}"`);
}
}
function assertNotIncludes(text: string, expected: string, name: string) {
if (text.includes(expected)) {
throw new Error(`${name}: expected not to include "${expected}", got "${text}"`);
}
}
const htmlEscaped = renderQaMarkdown('<img src=x onerror=alert(1)><script>alert(1)</script>');
assertIncludes(htmlEscaped, '&lt;img src=x onerror=alert(1)&gt;', 'raw html should be escaped');
assertNotIncludes(htmlEscaped, '<script>', 'script tag should not be rendered');
assertNotIncludes(htmlEscaped, '<img src=x onerror=alert(1)>', 'raw html img should not become live dom tag');
const badHref = renderQaMarkdown('[xss](javascript:alert(1))');
assertNotIncludes(badHref, 'href="javascript:alert(1)"', 'javascript href should be dropped');
const goodMarkdown = renderQaMarkdown('**bold**\n\n- a\n- b\n\n`code`\n\n[ok](https://example.com)');
assertIncludes(goodMarkdown, '<strong>bold</strong>', 'bold markdown should render');
assertIncludes(goodMarkdown, '<li>a</li>', 'list markdown should render');
assertIncludes(goodMarkdown, '<code>code</code>', 'code markdown should render');
assertIncludes(goodMarkdown, 'href="https://example.com"', 'safe link should render');
const safeQueryLink = renderQaMarkdown('[ok](https://example.com?a=1&b=2)');
assertIncludes(safeQueryLink, 'href="https://example.com?a=1&amp;b=2"', 'safe query link should keep a single escaped ampersand');
const codeSnippet = renderQaMarkdown('`<div class=\"x\">`');
assertIncludes(codeSnippet, '&lt;div class=&quot;x&quot;&gt;', 'inline code should stay single-escaped');
assertNotIncludes(codeSnippet, '&amp;lt;', 'inline code should not be double-escaped');
+65
View File
@@ -0,0 +1,65 @@
import { marked } from 'marked';
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function decodeHtmlEntities(raw: string): string {
return raw
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
function normalizeHref(href: string | null | undefined): string | null {
if (!href) return null;
const trimmed = href.trim();
if (!trimmed) return null;
if (trimmed.startsWith('#')) return trimmed;
try {
const url = new URL(trimmed, 'https://openless.local/');
if (!SAFE_LINK_PROTOCOLS.has(url.protocol)) return null;
return trimmed;
} catch {
return null;
}
}
const QA_MARKDOWN_RENDERER = new marked.Renderer();
QA_MARKDOWN_RENDERER.html = (html: string) => escapeHtml(html);
QA_MARKDOWN_RENDERER.link = (href: string | null, title: string | null, text: string) => {
const safeHref = normalizeHref(decodeHtmlEntities(href ?? ''));
if (!safeHref) return `<span>${text}</span>`;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noreferrer noopener">${text}</a>`;
};
QA_MARKDOWN_RENDERER.image = (href: string | null, title: string | null, text: string) => {
const safeHref = normalizeHref(decodeHtmlEntities(href ?? ''));
if (!safeHref) return '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
const alt = escapeHtml(text || '');
return `<img src="${escapeHtml(safeHref)}" alt="${alt}"${titleAttr} loading="lazy" />`;
};
export function renderQaPlainText(raw: string): string {
return `<pre>${escapeHtml(raw)}</pre>`;
}
export function renderQaMarkdown(markdown: string): string {
// 保留 markdown 语义(尤其代码块),但把 raw HTML token 转义为纯文本,避免注入。
return marked.parse(markdown, {
async: false,
gfm: true,
breaks: true,
renderer: QA_MARKDOWN_RENDERER,
}) as string;
}
+5 -7
View File
@@ -12,15 +12,13 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown';
const SELECTION_PREVIEW_MAX = 60;
marked.setOptions({ gfm: true, breaks: true });
type Status = 'idle' | 'recording' | 'thinking' | 'error';
export function QaPanel() {
@@ -405,10 +403,10 @@ function MessageRow({ message }: { message: QaChatMessage }) {
const html = useMemo(() => {
if (message.role !== 'assistant') return '';
try {
return marked.parse(message.content, { async: false }) as string;
return renderQaMarkdown(message.content);
} catch (error) {
console.error('[qa] failed to render markdown', error);
return message.content;
return renderQaPlainText(String(message.content ?? ''));
}
}, [message.content, message.role]);
@@ -445,10 +443,10 @@ function MessageRow({ message }: { message: QaChatMessage }) {
function StreamingAssistantBubble({ markdown }: { markdown: string }) {
const html = useMemo(() => {
try {
return marked.parse(markdown, { async: false }) as string;
return renderQaMarkdown(markdown);
} catch (error) {
console.error('[qa] failed to render streaming markdown', error);
return markdown;
return renderQaPlainText(String(markdown ?? ''));
}
}, [markdown]);
return (