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:
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, '<img src=x onerror=alert(1)>', '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');
|
||||
|
||||
@@ -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, '&')
|
||||
.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.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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user