From 053b46e371e1af5f1053deceb0a221a51480d363 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 13:27:44 -0700 Subject: [PATCH] fix: harden trust boundary markers against escape attacks - Sanitize URLs in markers (remove newlines, cap at 200 chars) to prevent marker injection via history.pushState - Escape marker strings in content (zero-width space) so malicious pages can't forge the END marker to break out of the untrusted block - Wrap resume command snapshot with trust boundary markers - Wrap diff command output with trust boundary markers - Wrap watch stop last snapshot with trust boundary markers Found by cross-model adversarial review (Claude + Codex). --- browse/src/commands.ts | 6 +++++- browse/src/meta-commands.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/browse/src/commands.ts b/browse/src/commands.ts index f2ae8935..bc521293 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -48,7 +48,11 @@ export const PAGE_CONTENT_COMMANDS = new Set([ /** Wrap output from untrusted-content commands with trust boundary markers */ export function wrapUntrustedContent(result: string, url: string): string { - return `--- BEGIN UNTRUSTED EXTERNAL CONTENT (source: ${url}) ---\n${result}\n--- END UNTRUSTED EXTERNAL CONTENT ---`; + // Sanitize URL: remove newlines to prevent marker injection via history.pushState + const safeUrl = url.replace(/[\n\r]/g, '').slice(0, 200); + // Escape marker strings in content to prevent boundary escape attacks + const safeResult = result.replace(/--- (BEGIN|END) UNTRUSTED EXTERNAL CONTENT/g, '--- $1 UNTRUSTED EXTERNAL C\u200BONTENT'); + return `--- BEGIN UNTRUSTED EXTERNAL CONTENT (source: ${safeUrl}) ---\n${safeResult}\n--- END UNTRUSTED EXTERNAL CONTENT ---`; } export const COMMAND_DESCRIPTIONS: Record = { diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index be0810ec..e2060c21 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -291,7 +291,7 @@ export async function handleMetaCommand( } } - return output.join('\n'); + return wrapUntrustedContent(output.join('\n'), `diff: ${url1} vs ${url2}`); } // ─── Snapshot ───────────────────────────────────── @@ -310,7 +310,7 @@ export async function handleMetaCommand( bm.resume(); // Re-snapshot to capture current page state after human interaction const snapshot = await handleSnapshot(['-i'], bm); - return `RESUMED\n${snapshot}`; + return `RESUMED\n${wrapUntrustedContent(snapshot, bm.getCurrentUrl())}`; } // ─── Headed Mode ────────────────────────────────────── @@ -381,11 +381,14 @@ export async function handleMetaCommand( if (!bm.isWatching()) return 'Not currently watching.'; const result = bm.stopWatch(); const durationSec = Math.round(result.duration / 1000); + const lastSnapshot = result.snapshots.length > 0 + ? wrapUntrustedContent(result.snapshots[result.snapshots.length - 1], bm.getCurrentUrl()) + : '(none)'; return [ `WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`, '', 'Last snapshot:', - result.snapshots.length > 0 ? result.snapshots[result.snapshots.length - 1] : '(none)', + lastSnapshot, ].join('\n'); }