Merge pull request #241 from H-Chris233/main
security(qa): sanitize assistant markdown rendering in QA panel
This commit is contained in:
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, '<img src=x onerror=alert(1)>', '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&b=2"', 'safe query link should keep a single escaped ampersand');
|
||||
|
||||
const codeSnippet = renderQaMarkdown('`<div class=\"x\">`');
|
||||
assertIncludes(codeSnippet, '<div class="x">', 'inline code should stay single-escaped');
|
||||
assertNotIncludes(codeSnippet, '&lt;', 'inline code should not be double-escaped');
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(raw: string): string {
|
||||
return raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user