diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
index c217a769a6..456534d8bc 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
@@ -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();
let renderedBlocks = $state([]);
@@ -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();
@@ -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;
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts
index e973a6a4b5..39971a38c9 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts
@@ -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();
-const attachmentPipelines = new WeakMap
',
+ SAFE_HTML_CONFIG
+ ) as string;
+
+ expect(clean).toContain('href="https://example.com"');
+ expect(clean).toContain('
{
+ it('renders an allowHtml payload README without executing it', async () => {
+ const evil = [
+ '# Evil card',
+ '',
+ '
',
+ 'x',
+ '',
+ '',
+ '',
+ '',
+ '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: '
'
+ },
+ 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);
+ });
+});