Harden QA markdown rendering to block WebView script injection

Issue #221 requires closing the QA markdown XSS surface without broad CSP or architecture changes. This switches assistant and streaming markdown to one shared safe renderer that escapes raw HTML and rejects non-http(s)/mailto/tel links.

Constraint: Keep markdown readability while preventing executable HTML in QA WebView

Rejected: Add DOMPurify dependency | avoid new dependency for minimal fix

Confidence: medium

Scope-risk: narrow

Directive: Keep streaming and final assistant rendering on the same sanitizer path

Tested: npm run build (openless-all/app)

Not-tested: Manual malicious payload interaction in packaged desktop app
This commit is contained in:
H-Chris233 committed 2026-05-04 17:21:26 +08:00
1 parent f87e598044
commit a57978e739
3 files changed
+82 -5

No files matched your search

@@ -0,0 +1,28 @@
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, 'onerror=', 'event attribute should stay inert');
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');
+51
View File
@@ -0,0 +1,51 @@
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 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.link = (href: string | null, title: string | null, text: string) => {
const safeHref = normalizeHref(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(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 renderQaMarkdown(markdown: string): string {
// 禁用 markdown 中 raw HTML:统一转义后再走 marked,仅保留安全 markdown 子集。
const escaped = escapeHtml(markdown);
return marked.parse(escaped, {
async: false,
gfm: true,
renderer: QA_MARKDOWN_RENDERER,
}) as string;
}
+3 -5
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 } from '../lib/qaMarkdown';
const SELECTION_PREVIEW_MAX = 60;
marked.setOptions({ gfm: true, breaks: true });
type Status = 'idle' | 'recording' | 'thinking' | 'error';
export function QaPanel() {
@@ -405,7 +403,7 @@ 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;
@@ -445,7 +443,7 @@ 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;