diff --git a/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts b/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts index effd1cd7..d36ee901 100644 --- a/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts +++ b/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts @@ -138,6 +138,11 @@ describe('classifyMention', () => { expect(classifyMention('', classifier)).toBeNull(); }); + test('HTML fragments do not classify as file references', () => { + expect(classifyMention('import', classifier)).toBeNull(); + expect(classifyMention('src/ { const shadowed = { knownAgentNames: new Set(['a.ts']), @@ -152,4 +157,3 @@ describe('classifyMention', () => { expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true); }); }); - diff --git a/packages/ui/src/components/chat/composer/language/mentions.ts b/packages/ui/src/components/chat/composer/language/mentions.ts index 7bedd7cc..ed79d68d 100644 --- a/packages/ui/src/components/chat/composer/language/mentions.ts +++ b/packages/ui/src/components/chat/composer/language/mentions.ts @@ -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` 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; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts new file mode 100644 index 00000000..86db0bbd --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -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 = ''; + + expect(escapeRawMarkdownHtml(payload)).toBe( + '<style>@import url("https://example.test/theme.css");</style>', + ); + }); + + test('forbids script and stylesheet elements as active content', () => { + expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script'); + expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style'); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 250fc1cd..ad906d99 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -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, '&').replace(/"/g, '"').replace(//g, '>'); @@ -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; diff --git a/packages/ui/src/components/chat/markdown/markdownSecurity.ts b/packages/ui/src/components/chat/markdown/markdownSecurity.ts new file mode 100644 index 00000000..158ffa0b --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdownSecurity.ts @@ -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, '&').replace(/"/g, '"').replace(//g, '>'); + +/** Active elements forbidden again at the final DOMPurify boundary. */ +export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 02fbd092..aaf0b2b4 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -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.