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
@@ -138,6 +138,11 @@ describe('classifyMention', () => {
expect(classifyMention('', classifier)).toBeNull();
});
test('HTML fragments do not classify as file references', () => {
expect(classifyMention('import</style>', classifier)).toBeNull();
expect(classifyMention('src/<style.css', classifier)).toBeNull();
});
test('an agent name wins over a file-looking name', () => {
const shadowed = {
knownAgentNames: new Set(['a.ts']),
@@ -152,4 +157,3 @@ describe('classifyMention', () => {
expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true);
});
});
@@ -116,6 +116,9 @@ export function classifyMention(
classifier: MentionClassifier,
): MentionKind | null {
if (!name) return null;
// HTML fragments are prompt text, never references. In particular, do not
// interpret CSS syntax such as `@import</style>` as a local file path.
if (name.includes('<') || name.includes('>')) return null;
if (classifier.knownAgentNames.has(name.toLowerCase())) return 'agent';
if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file';
return null;
@@ -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;
@@ -51,6 +51,10 @@ Use this doc when you ask an agent to change tool/header/description behavior.
## Current important behavior
- Assistant markdown treats raw HTML as inert visible text. The final generated
HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface.
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.