fix(ui): block active HTML in assistant markdown

This commit is contained in:
Bohdan Triapitsyn
2026-08-02 16:12:23 +03:00
parent 7afec99f80
commit b1ec34162e
6 changed files with 47 additions and 3 deletions
@@ -0,0 +1,18 @@
import { describe, expect, test } from 'bun:test';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => {
const payload = '<style>@import url("https://example.test/theme.css");</style>';
expect(escapeRawMarkdownHtml(payload)).toBe(
'&lt;style&gt;@import url(&quot;https://example.test/theme.css&quot;);&lt;/style&gt;',
);
});
test('forbids script and stylesheet elements as active content', () => {
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
});
});
@@ -5,6 +5,7 @@ import DOMPurify from 'dompurify';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isVSCodeRuntime } from '@/lib/desktop';
import { highlightCodeInWorker } from './markdown-worker';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -166,6 +167,12 @@ const parser = marked.use({
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
renderer: {
// Assistant output is untrusted. Markdown constructs still render as HTML,
// but raw HTML must remain visible text so it cannot introduce active DOM
// such as stylesheets or positioned overlays into the application shell.
html({ text }) {
return escapeRawMarkdownHtml(text);
},
link({ href, title, text }) {
const target = href ?? '';
const agentName = parseAgentHref(target);
@@ -283,8 +290,10 @@ const SANITIZE_CONFIG = {
USE_PROFILES: { html: true, mathMl: true, svg: true },
ADD_TAGS: ['svg', 'path', 'g', 'rect', 'line', 'polygon', 'polyline', 'circle', 'ellipse', 'text', 'tspan', 'defs', 'marker'],
ADD_ATTR: ['d', 'viewBox', 'preserveAspectRatio', 'xmlns', 'target', 'fill', 'stroke', 'stroke-width', 'transform', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'style'],
FORBID_TAGS: ['script'],
FORBID_CONTENTS: ['script'],
// Defense in depth for generated/highlighter HTML after raw markdown HTML
// has been escaped by the marked renderer above.
FORBID_TAGS: [...MARKDOWN_FORBIDDEN_TAGS],
FORBID_CONTENTS: [...MARKDOWN_FORBIDDEN_TAGS],
};
let sanitizeHookInstalled = false;
@@ -0,0 +1,6 @@
/** Raw HTML in assistant markdown is untrusted and must stay inert text. */
export const escapeRawMarkdownHtml = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Active elements forbidden again at the final DOMPurify boundary. */
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;