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:
Aleksander Grygier
2026-09-07 22:10:09 +02:00
parent 187276d8d1
commit 7f59290fd0
4 changed files with 325 additions and 14 deletions
@@ -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&colon;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);
});
});