mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-11 04:56:56 +02:00
ui : optional sanitized raw HTML in markdown
Add an allowHtml prop to MarkdownContent: raw HTML found in the markdown is rendered after DOMPurify sanitization instead of being escaped as literal text. Default stays escaped. Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
getMdastNodeHash,
|
||||
isAppendMode
|
||||
} from './markdown-utils';
|
||||
import { SAFE_HTML_CONFIG } from './safe-html-config';
|
||||
import {
|
||||
ActionIconCopyToClipboard,
|
||||
CodeBlockActions,
|
||||
@@ -44,6 +45,7 @@
|
||||
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
|
||||
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
|
||||
import { mountSvgShadow } from '$lib/utils/svg-shadow';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
@@ -57,6 +59,8 @@
|
||||
content: string;
|
||||
class?: string;
|
||||
disableMath?: boolean;
|
||||
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
|
||||
allowHtml?: boolean;
|
||||
}
|
||||
|
||||
interface MarkdownBlock {
|
||||
@@ -65,7 +69,13 @@
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
|
||||
let {
|
||||
allowHtml = false,
|
||||
attachments,
|
||||
class: className = '',
|
||||
content,
|
||||
disableMath = false
|
||||
}: Props = $props();
|
||||
|
||||
let containerRef = $state<HTMLDivElement>();
|
||||
let renderedBlocks = $state<MarkdownBlock[]>([]);
|
||||
@@ -119,6 +129,11 @@
|
||||
let pendingMarkdown: string | null = null;
|
||||
let isProcessing = false;
|
||||
|
||||
// Raw HTML in model cards renders after sanitization with an explicit allow
|
||||
// list: no scripts, event handlers, forms, iframes or style blocks pass.
|
||||
// Covers the tags model cards use plus the KaTeX output (spans with inline
|
||||
// styles and MathML).
|
||||
|
||||
// Per-instance transform cache, avoids re-transforming stable blocks during streaming
|
||||
// Garbage collected when component is destroyed (on conversation change)
|
||||
const transformCache = new SvelteMap<string, string>();
|
||||
@@ -183,7 +198,10 @@
|
||||
index: number
|
||||
): Promise<{ html: string; hash: string }> {
|
||||
const hash = getMdastNodeHash(node, index);
|
||||
const cached = transformCache.get(hash);
|
||||
// the rendered HTML also depends on allowHtml (sanitized raw vs escaped),
|
||||
// so the cache is keyed per mode
|
||||
const cacheKey = `${allowHtml ? 'a' : 'p'}:${hash}`;
|
||||
const cached = transformCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { hash, html: cached };
|
||||
@@ -192,10 +210,13 @@
|
||||
const singleNodeRoot = { children: [node], type: 'root' };
|
||||
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
|
||||
const html = processorInstance.stringify(transformedRoot);
|
||||
const safeHtml = allowHtml
|
||||
? (DOMPurify.sanitize(html, SAFE_HTML_CONFIG) as unknown as string)
|
||||
: html;
|
||||
|
||||
transformCache.set(hash, html);
|
||||
transformCache.set(cacheKey, safeHtml);
|
||||
|
||||
return { hash, html };
|
||||
return { hash, html: safeHtml };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,7 +321,7 @@
|
||||
|
||||
if (prefixMarkdown.trim()) {
|
||||
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const processorInstance = getMarkdownProcessor({ allowHtml, attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
@@ -350,7 +371,7 @@
|
||||
incompleteCodeBlock = null;
|
||||
|
||||
const normalized = preprocessLaTeX(markdown);
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const processorInstance = getMarkdownProcessor({ allowHtml, attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalized) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const stableCount = Math.max(mdastChildren.length - 1, 0);
|
||||
@@ -394,6 +415,10 @@
|
||||
)) as HastRoot;
|
||||
|
||||
unstableHtml = processorInstance.stringify(transformedRoot);
|
||||
|
||||
if (allowHtml) {
|
||||
unstableHtml = DOMPurify.sanitize(unstableHtml, SAFE_HTML_CONFIG) as unknown as string;
|
||||
}
|
||||
}
|
||||
|
||||
renderedBlocks = nextBlocks;
|
||||
|
||||
@@ -41,12 +41,15 @@ export interface MarkdownProcessor {
|
||||
export interface MarkdownProcessorOptions {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
disableMath?: boolean;
|
||||
/** Render raw HTML found in the markdown instead of escaping it. */
|
||||
allowHtml?: boolean;
|
||||
}
|
||||
|
||||
const sharedPipelines = new Map<string, MarkdownProcessor>();
|
||||
const attachmentPipelines = new WeakMap<object, MarkdownProcessor>();
|
||||
const attachmentPipelines = new WeakMap<object, Map<string, MarkdownProcessor>>();
|
||||
|
||||
function buildPipeline({
|
||||
allowHtml = false,
|
||||
attachments,
|
||||
disableMath = false
|
||||
}: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
@@ -57,11 +60,15 @@ function buildPipeline({
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
|
||||
|
||||
if (!allowHtml) {
|
||||
// Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkLiteralHtml)
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
proc = proc.use(remarkLiteralHtml);
|
||||
}
|
||||
|
||||
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
|
||||
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
@@ -89,17 +96,26 @@ function buildPipeline({
|
||||
|
||||
export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
if (options.attachments && options.attachments.length > 0) {
|
||||
let cached = attachmentPipelines.get(options.attachments);
|
||||
let byOptions = attachmentPipelines.get(options.attachments);
|
||||
|
||||
if (!byOptions) {
|
||||
byOptions = new Map<string, MarkdownProcessor>();
|
||||
attachmentPipelines.set(options.attachments, byOptions);
|
||||
}
|
||||
|
||||
const key = `${Boolean(options.disableMath)}:${Boolean(options.allowHtml)}`;
|
||||
|
||||
let cached = byOptions.get(key);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
attachmentPipelines.set(options.attachments, cached);
|
||||
byOptions.set(key, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
const key = String(Boolean(options.disableMath));
|
||||
const key = `${Boolean(options.disableMath)}:${Boolean(options.allowHtml)}`;
|
||||
|
||||
let cached = sharedPipelines.get(key);
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Sanitization rules for raw HTML in model cards (MarkdownContent's
|
||||
// allowHtml mode). Kept as its own module so the adversarial test in
|
||||
// tests/client can import the exact shipped configuration.
|
||||
|
||||
/**
|
||||
* Explicit DOMPurify allow list: the tags and attributes model cards may use
|
||||
* plus the KaTeX output (spans with inline styles and MathML). Everything
|
||||
* else - scripts, event handlers, forms, iframes, style tags - is stripped.
|
||||
*/
|
||||
export const SAFE_HTML_CONFIG = {
|
||||
ALLOWED_ATTR: [
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'title',
|
||||
'role',
|
||||
'aria-hidden',
|
||||
'aria-label',
|
||||
'aria-describedby',
|
||||
'aria-expanded',
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'src',
|
||||
'srcset',
|
||||
'alt',
|
||||
'width',
|
||||
'height',
|
||||
'align',
|
||||
'valign',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'controls',
|
||||
'type',
|
||||
// MathML
|
||||
'xmlns',
|
||||
'display',
|
||||
'encoding',
|
||||
'mathvariant'
|
||||
],
|
||||
ALLOWED_TAGS: [
|
||||
// text and structure
|
||||
'a',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'em',
|
||||
'strong',
|
||||
'code',
|
||||
'pre',
|
||||
'kbd',
|
||||
'sub',
|
||||
'sup',
|
||||
'br',
|
||||
'hr',
|
||||
'p',
|
||||
'span',
|
||||
'div',
|
||||
'blockquote',
|
||||
'center',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'colgroup',
|
||||
'col',
|
||||
'caption',
|
||||
// media
|
||||
'img',
|
||||
'picture',
|
||||
'source',
|
||||
'figure',
|
||||
'figcaption',
|
||||
'video',
|
||||
'audio',
|
||||
// MathML, for the KaTeX output
|
||||
'math',
|
||||
'mrow',
|
||||
'mi',
|
||||
'mo',
|
||||
'mn',
|
||||
'ms',
|
||||
'mtext',
|
||||
'mfrac',
|
||||
'mroot',
|
||||
'msqrt',
|
||||
'msub',
|
||||
'msup',
|
||||
'msubsup',
|
||||
'munder',
|
||||
'mover',
|
||||
'mmultiscripts',
|
||||
'mtable',
|
||||
'mtr',
|
||||
'mtd',
|
||||
'mspace',
|
||||
'mglyph',
|
||||
'maligngroup',
|
||||
'malignmark',
|
||||
'mpadded',
|
||||
'mphantom',
|
||||
'mstyle',
|
||||
'semantics',
|
||||
'annotation'
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// Adversarial checks for MarkdownContent's allowHtml mode: the discover README
|
||||
// renders HuggingFace model cards, which are third-party content, so every
|
||||
// payload below must come out neutralized - both right after sanitize and
|
||||
// after an innerHTML re-parse, which is where mutation XSS would surface.
|
||||
import MarkdownContent from '$lib/components/app/content/MarkdownContent/MarkdownContent.svelte';
|
||||
import { SAFE_HTML_CONFIG } from '$lib/components/app/content/MarkdownContent/safe-html-config';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { mount, unmount } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const HANDLER_ATTR = /\son[a-z]+\s*=/i;
|
||||
const JS_URL = /javascript\s*:/i;
|
||||
|
||||
/** Payload execution canary: set by any payload that runs. */
|
||||
function xssFired(): boolean {
|
||||
return (window as { __mdXss?: number }).__mdXss !== undefined;
|
||||
}
|
||||
|
||||
function roundTrip(html: string): string {
|
||||
const el = document.createElement('div');
|
||||
|
||||
el.innerHTML = html;
|
||||
document.body.appendChild(el);
|
||||
const again = el.innerHTML;
|
||||
|
||||
el.remove();
|
||||
|
||||
return again;
|
||||
}
|
||||
|
||||
const PAYLOADS: Record<string, string> = {
|
||||
'details ontoggle': '<details open ontoggle="window.__mdXss=1">x</details>',
|
||||
'dom clobbering': '<p id="content" name="location"><input name="domain"></p>',
|
||||
'form and input': '<form><input autofocus onfocus="window.__mdXss=1"></form>',
|
||||
'iframe javascript src': '<iframe src="javascript:alert(1)"></iframe>',
|
||||
'iframe srcdoc': '<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
|
||||
'img onerror': '<img src=x onerror="window.__mdXss=1">',
|
||||
'link case and entity href': '<a href="JaVaScRiPt:alert(1)">x</a>',
|
||||
'link data:text/html href':
|
||||
'<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>',
|
||||
'link javascript href': '<a href="javascript:alert(1)">x</a>',
|
||||
'link vbscript href': '<a href="vbscript:msgbox(1)">x</a>',
|
||||
'math href': '<math href="javascript:alert(1)"></math>',
|
||||
'mathml annotation-xml integration point':
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
|
||||
'mathml mtext style mXSS':
|
||||
'<math><mtext><table><mglyph><style><img src=x onerror="window.__mdXss=1"></style></mglyph></table></mtext></math>',
|
||||
noscript: '<noscript><p title="</noscript><img src=x onerror="window.__mdXss=1>">',
|
||||
script: '<script>window.__mdXss=1</script>',
|
||||
'srcset javascript': '<img srcset="javascript:alert(1) 1x, x 2x" src=x>',
|
||||
'style tag': '<style>@import url(javascript:alert(1));</style>',
|
||||
'svg foreignObject smuggling':
|
||||
'<svg><foreignObject><img src=x onerror="window.__mdXss=1"></foreignObject></svg>',
|
||||
'svg set-attribute animation': '<svg><set attributeName="onmouseover" to="alert(1)"/></svg>',
|
||||
'unclosed tag': '<img src="x" onerror="window.__mdXss=1"',
|
||||
'uppercase tags': '<IMG SRC=x ONERROR="window.__mdXss=1">',
|
||||
'video source onerror': '<video><source onerror="window.__mdXss=1" src=x></video>',
|
||||
'xlink href': '<math><mtext xlink:href="javascript:alert(1)"></mtext></math>'
|
||||
};
|
||||
|
||||
describe('SAFE_HTML_CONFIG adversarial battery', () => {
|
||||
it('neutralizes every payload after sanitize and after innerHTML re-parse', () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [name, payload] of Object.entries(PAYLOADS)) {
|
||||
const clean = DOMPurify.sanitize(payload, SAFE_HTML_CONFIG) as string;
|
||||
const again = roundTrip(clean);
|
||||
|
||||
if (HANDLER_ATTR.test(clean) || HANDLER_ATTR.test(again)) {
|
||||
failures.push(`${name}: handler attribute survived: ${again}`);
|
||||
}
|
||||
|
||||
if (JS_URL.test(clean) || JS_URL.test(again)) {
|
||||
failures.push(`${name}: javascript: URL survived: ${again}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps benign model-card markup intact', () => {
|
||||
const clean = DOMPurify.sanitize(
|
||||
'<h2 id="title">Title</h2><p>Text with <a href="https://example.com" target="_blank" rel="noopener">a link</a>, <img src="https://example.com/i.png" alt="i" width="100">, <code>code</code>, <table><tr><td colspan="2">cell</td></tr></table>, <math><semantics><annotation encoding="application/x-tex">x^2</annotation></semantics></math></p>',
|
||||
SAFE_HTML_CONFIG
|
||||
) as string;
|
||||
|
||||
expect(clean).toContain('href="https://example.com"');
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('colspan="2"');
|
||||
expect(clean).toContain('<annotation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownContent end to end', () => {
|
||||
it('renders an allowHtml payload README without executing it', async () => {
|
||||
const evil = [
|
||||
'# Evil card',
|
||||
'',
|
||||
'<img src=x onerror="window.__mdXss=1">',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<script>window.__mdXss=1</script>',
|
||||
'<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
|
||||
'',
|
||||
'normal **markdown** continues'
|
||||
].join('\n');
|
||||
const target = document.createElement('div');
|
||||
|
||||
document.body.appendChild(target);
|
||||
mount(MarkdownContent, { props: { allowHtml: true, content: evil }, target });
|
||||
|
||||
for (let i = 0; i < 100 && !target.textContent?.includes('normal'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(target.textContent).toContain('normal');
|
||||
expect(xssFired()).toBe(false);
|
||||
expect(target.querySelector('script')).toBeNull();
|
||||
expect(target.querySelector('iframe')).toBeNull();
|
||||
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('escapes raw HTML to literal text in the default mode', async () => {
|
||||
const target = document.createElement('div');
|
||||
|
||||
document.body.appendChild(target);
|
||||
const component = mount(MarkdownContent, {
|
||||
props: {
|
||||
content: '<script>window.__mdXss=1</script><img src=x onerror="window.__mdXss=1">'
|
||||
},
|
||||
target
|
||||
});
|
||||
|
||||
for (let i = 0; i < 100 && !target.textContent?.includes('onerror'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(xssFired()).toBe(false);
|
||||
expect(target.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(target.textContent).toContain('onerror');
|
||||
|
||||
target.remove();
|
||||
|
||||
if (component) unmount(component);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user