fix(ui): block active HTML in assistant markdown
This commit is contained in:
@@ -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(
|
||||
'<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 { 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, '<').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;
|
||||
|
||||
@@ -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
|
||||
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user