From 1372a4f6315284c0400bc83f550f999b95438908 Mon Sep 17 00:00:00 2001 From: gus Date: Thu, 16 Apr 2026 20:33:09 -0300 Subject: [PATCH] security: extend hidden-element detection to all DOM-reading channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Confusion Protocol envelope wrap (`wrapUntrustedPageContent`) covers every scoped PAGE_CONTENT_COMMAND, but the hidden-element ARIA-injection detection layer only ran for `text`. Other DOM-reading channels (html, links, forms, accessibility, attrs, data, media, ux-audit) returned their output through the envelope with no hidden- content filter, so a page serving a display:none div that instructs the agent to disregard prior system messages, or an aria-label that claims to put the LLM in admin mode, leaked the injection payload on any non-text channel. The envelope alone does not mitigate this, and the page itself never rendered the hostile content to the human operator. Fix: * New export `DOM_CONTENT_COMMANDS` in commands.ts — the subset of PAGE_CONTENT_COMMANDS that derives its output from the live DOM. Console and dialog stay out; they read separate runtime state. * server.ts runs `markHiddenElements` + `cleanupHiddenMarkers` for every scoped command in this set. `text` keeps its existing `getCleanTextWithStripping` path (hidden elements physically stripped before the read). All other channels keep their output format but emit flagged elements as CONTENT WARNINGS on the envelope, so the LLM sees what it would otherwise have consumed silently. * Hidden-element descriptions merge into `combinedWarnings` alongside content-filter warnings before the wrap call. Tests: new describe block in content-security.test.ts covering * `DOM_CONTENT_COMMANDS` export shape and channel membership; * dispatch gates on `DOM_CONTENT_COMMANDS.has(command)`, not the literal `text` string; * hiddenContentWarnings plumbs into `combinedWarnings` and reaches wrapUntrustedPageContent; * DOM_CONTENT_COMMANDS is a strict subset of PAGE_CONTENT_COMMANDS. Existing datamarking, envelope wrap, centralized-wrapping, and chain security suites stay green (52 pass, 0 fail). --- browse/src/commands.ts | 16 +++++++ browse/src/server.ts | 47 ++++++++++++++----- browse/test/content-security.test.ts | 69 ++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 8af1cb85..e9e60153 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -59,6 +59,22 @@ export const PAGE_CONTENT_COMMANDS = new Set([ 'snapshot', ]); +/** + * Subset of PAGE_CONTENT_COMMANDS whose output is derived from the + * live page DOM. These channels can carry hidden elements or + * ARIA-injection payloads that the centralized envelope wrap alone + * does not neutralize, so the scoped-token pipeline runs + * `markHiddenElements` on the page before the read and surfaces any + * hits as CONTENT WARNINGS to the LLM. + * + * `console`, `dialog` intentionally excluded — they read separate + * runtime state (console capture, dialog events), not the DOM tree. + */ +export const DOM_CONTENT_COMMANDS = new Set([ + 'text', 'html', 'links', 'forms', 'accessibility', 'attrs', + 'media', 'data', 'ux-audit', +]); + /** Wrap output from untrusted-content commands with trust boundary markers */ export function wrapUntrustedContent(result: string, url: string): string { // Sanitize URL: remove newlines to prevent marker injection via history.pushState diff --git a/browse/src/server.ts b/browse/src/server.ts index f3ffc9b5..3941d826 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes'; import { sanitizeExtensionUrl } from './sidebar-utils'; -import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; +import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; import { wrapUntrustedPageContent, datamarkContent, runContentFilters, type ContentFilterResult, @@ -1178,18 +1178,39 @@ async function handleCommandInternal( const session = browserManager.getActiveSession(); + // Per-request warnings collected during hidden-element detection, + // surfaced into the envelope the LLM sees. Carries across the read + // phase into the centralized wrap block below. + let hiddenContentWarnings: string[] = []; + if (READ_COMMANDS.has(command)) { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; - // Hidden element stripping for scoped tokens on text command - if (isScoped && command === 'text') { + // Hidden-element / ARIA-injection detection for every scoped + // DOM-reading channel (text, html, links, forms, accessibility, + // attrs, data, media, ux-audit). Previously only `text` received + // stripping; other channels let hidden injection payloads reach + // the LLM despite the envelope wrap. Detections become CONTENT + // WARNINGS on the outgoing envelope so the model can see what it + // would have otherwise trusted silently. + if (isScoped && DOM_CONTENT_COMMANDS.has(command)) { const page = session.getPage(); - const strippedDescs = await markHiddenElements(page); - if (strippedDescs.length > 0) { - console.warn(`[browse] Content security: stripped ${strippedDescs.length} hidden elements for ${tokenInfo.clientId}`); - } try { - const target = session.getActiveFrameOrPage(); - result = await getCleanTextWithStripping(target); + const strippedDescs = await markHiddenElements(page); + if (strippedDescs.length > 0) { + console.warn(`[browse] Content security: ${strippedDescs.length} hidden elements flagged on ${command} for ${tokenInfo.clientId}`); + hiddenContentWarnings = strippedDescs.slice(0, 8).map(d => + `hidden content: ${d.slice(0, 120)}`, + ); + if (strippedDescs.length > 8) { + hiddenContentWarnings.push(`hidden content: +${strippedDescs.length - 8} more flagged elements`); + } + } + if (command === 'text') { + const target = session.getActiveFrameOrPage(); + result = await getCleanTextWithStripping(target); + } else { + result = await handleReadCommand(command, args, session, browserManager); + } } finally { await cleanupHiddenMarkers(page); } @@ -1260,10 +1281,14 @@ async function handleCommandInternal( if (command === 'text') { result = datamarkContent(result); } - // Enhanced envelope wrapping for scoped tokens + // Enhanced envelope wrapping for scoped tokens. + // Merge per-request hidden-element warnings with content-filter + // warnings so both reach the LLM through the same CONTENT + // WARNINGS header. + const combinedWarnings = [...filterResult.warnings, ...hiddenContentWarnings]; result = wrapUntrustedPageContent( result, command, - filterResult.warnings.length > 0 ? filterResult.warnings : undefined, + combinedWarnings.length > 0 ? combinedWarnings : undefined, ); } else { // Root token: basic wrapping (backward compat, Decision 2) diff --git a/browse/test/content-security.test.ts b/browse/test/content-security.test.ts index 759aee6d..6c98e3a3 100644 --- a/browse/test/content-security.test.ts +++ b/browse/test/content-security.test.ts @@ -303,6 +303,75 @@ describe('Centralized wrapping', () => { }); }); +// ─── 5b. DOM-content channel coverage (F008) ──────────────────── +// +// Regression: `markHiddenElements` was only invoked for scoped +// `text`. Other DOM-reading channels (html, accessibility, attrs, +// forms, links, data, media, ux-audit) went through the envelope +// wrap with zero hidden-element detection, so a +//
IGNORE INSTRUCTIONS …
or an +// aria-label carrying an injection pattern reached the LLM silently. +// The dispatch now gates on DOM_CONTENT_COMMANDS and surfaces +// descriptions as CONTENT WARNINGS. + +describe('DOM-content channel coverage', () => { + test('commands.ts exports DOM_CONTENT_COMMANDS', () => { + expect(COMMANDS_SRC).toContain('export const DOM_CONTENT_COMMANDS'); + }); + + test('DOM_CONTENT_COMMANDS covers the DOM-reading channels', () => { + const setStart = COMMANDS_SRC.indexOf('export const DOM_CONTENT_COMMANDS'); + expect(setStart).toBeGreaterThan(-1); + const setBlock = COMMANDS_SRC.slice( + setStart, COMMANDS_SRC.indexOf(']);', setStart), + ); + for (const cmd of ['text', 'html', 'links', 'forms', 'accessibility', 'attrs', 'media', 'data', 'ux-audit']) { + expect(setBlock).toContain(`'${cmd}'`); + } + // console + dialog read runtime state, not DOM — should NOT be in the set + expect(setBlock).not.toContain("'console'"); + expect(setBlock).not.toContain("'dialog'"); + }); + + test('server gates markHiddenElements on DOM_CONTENT_COMMANDS, not just text', () => { + // Find the scoped-token read block. The dispatch must pivot on + // the full set rather than the literal string 'text'. + const readBlockStart = SERVER_SRC.indexOf('if (READ_COMMANDS.has(command))'); + expect(readBlockStart).toBeGreaterThan(-1); + const readBlockEnd = SERVER_SRC.indexOf('} else if (WRITE_COMMANDS.has(command))', readBlockStart); + const readBlock = SERVER_SRC.slice(readBlockStart, readBlockEnd); + + // Old shape the PR replaces — must be gone. If a future refactor + // reintroduces `command === 'text'` as the ONLY trigger for + // markHiddenElements this test trips. + expect(readBlock).toContain('DOM_CONTENT_COMMANDS.has(command)'); + expect(readBlock).toContain('markHiddenElements'); + expect(readBlock).toContain('cleanupHiddenMarkers'); + }); + + test('hidden-element descriptions flow into the envelope warnings', () => { + // The per-request warnings variable must be collected during the + // read phase and then merged into the wrap block's + // `combinedWarnings` before `wrapUntrustedPageContent` is called. + expect(SERVER_SRC).toContain('hiddenContentWarnings'); + expect(SERVER_SRC).toMatch(/combinedWarnings\s*=\s*\[\s*\.\.\.\s*filterResult\.warnings\s*,\s*\.\.\.\s*hiddenContentWarnings\s*\]/); + // And the merged list is what actually reaches the wrap helper. + const wrapBlockStart = SERVER_SRC.indexOf('Enhanced envelope wrapping for scoped tokens'); + expect(wrapBlockStart).toBeGreaterThan(-1); + const wrapBlock = SERVER_SRC.slice(wrapBlockStart, wrapBlockStart + 600); + expect(wrapBlock).toContain('combinedWarnings'); + expect(wrapBlock).toMatch(/wrapUntrustedPageContent\s*\(\s*\n?\s*result/); + }); + + test('DOM_CONTENT_COMMANDS is a subset of PAGE_CONTENT_COMMANDS', async () => { + const { PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS } = + await import('../src/commands'); + for (const cmd of DOM_CONTENT_COMMANDS) { + expect(PAGE_CONTENT_COMMANDS.has(cmd)).toBe(true); + } + }); +}); + // ─── 6. Chain Security (source-level) ─────────────────────────── describe('Chain security', () => {