fix(ui): block active HTML in assistant markdown
This commit is contained in:
@@ -138,6 +138,11 @@ describe('classifyMention', () => {
|
|||||||
expect(classifyMention('', classifier)).toBeNull();
|
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', () => {
|
test('an agent name wins over a file-looking name', () => {
|
||||||
const shadowed = {
|
const shadowed = {
|
||||||
knownAgentNames: new Set(['a.ts']),
|
knownAgentNames: new Set(['a.ts']),
|
||||||
@@ -152,4 +157,3 @@ describe('classifyMention', () => {
|
|||||||
expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true);
|
expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ export function classifyMention(
|
|||||||
classifier: MentionClassifier,
|
classifier: MentionClassifier,
|
||||||
): MentionKind | null {
|
): MentionKind | null {
|
||||||
if (!name) return 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 (classifier.knownAgentNames.has(name.toLowerCase())) return 'agent';
|
||||||
if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file';
|
if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file';
|
||||||
return null;
|
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(
|
||||||
|
'<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import DOMPurify from 'dompurify';
|
|||||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { highlightCodeInWorker } from './markdown-worker';
|
import { highlightCodeInWorker } from './markdown-worker';
|
||||||
|
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||||
|
|
||||||
const escapeAttr = (value: string): string =>
|
const escapeAttr = (value: string): string =>
|
||||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -166,6 +167,12 @@ const parser = marked.use({
|
|||||||
breaks: false,
|
breaks: false,
|
||||||
extensions: [inlineMathExtension, blockMathExtension],
|
extensions: [inlineMathExtension, blockMathExtension],
|
||||||
renderer: {
|
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 }) {
|
link({ href, title, text }) {
|
||||||
const target = href ?? '';
|
const target = href ?? '';
|
||||||
const agentName = parseAgentHref(target);
|
const agentName = parseAgentHref(target);
|
||||||
@@ -283,8 +290,10 @@ const SANITIZE_CONFIG = {
|
|||||||
USE_PROFILES: { html: true, mathMl: true, svg: true },
|
USE_PROFILES: { html: true, mathMl: true, svg: true },
|
||||||
ADD_TAGS: ['svg', 'path', 'g', 'rect', 'line', 'polygon', 'polyline', 'circle', 'ellipse', 'text', 'tspan', 'defs', 'marker'],
|
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'],
|
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'],
|
// Defense in depth for generated/highlighter HTML after raw markdown HTML
|
||||||
FORBID_CONTENTS: ['script'],
|
// has been escaped by the marked renderer above.
|
||||||
|
FORBID_TAGS: [...MARKDOWN_FORBIDDEN_TAGS],
|
||||||
|
FORBID_CONTENTS: [...MARKDOWN_FORBIDDEN_TAGS],
|
||||||
};
|
};
|
||||||
|
|
||||||
let sanitizeHookInstalled = false;
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
/** 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
|
## 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`.
|
- `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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user