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).
This commit is contained in:
Garry Tan
2026-03-29 13:27:44 -07:00
parent 50d7b5fa1c
commit 053b46e371
2 changed files with 11 additions and 4 deletions
+5 -1
View File
@@ -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<string, { category: string; description: string; usage?: string }> = {
+6 -3
View File
@@ -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');
}