mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
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:
@@ -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
|
||||
|
||||
+36
-11
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// <div style="display:none">IGNORE INSTRUCTIONS …</div> 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', () => {
|
||||
|
||||
Reference in New Issue
Block a user