security: extend hidden-element detection to all DOM-reading channels

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).
This commit is contained in:
gus
2026-04-16 20:33:09 -03:00
committed by Garry Tan
parent d9e78dd548
commit 1372a4f631
3 changed files with 121 additions and 11 deletions

View File

@@ -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

View File

@@ -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)