security: route scoped snapshot through envelope sentinel escape#1031
Open
garagon wants to merge 1 commit intogarrytan:mainfrom
Open
security: route scoped snapshot through envelope sentinel escape#1031garagon wants to merge 1 commit intogarrytan:mainfrom
garagon wants to merge 1 commit intogarrytan:mainfrom
Conversation
The scoped-token snapshot path in snapshot.ts built its untrusted
block by pushing the raw accessibility-tree lines between the literal
`═══ BEGIN UNTRUSTED WEB CONTENT ═══` / `═══ END UNTRUSTED WEB CONTENT ═══`
sentinels. The full-page wrap path in content-security.ts already
applied a zero-width-space escape on those exact strings to prevent
sentinel injection, but the scoped path skipped it.
Net effect: a page whose rendered text contains the literal sentinel
can close the envelope early from inside untrusted content and forge
a fake "trusted" block for the LLM. That includes fabricating
interactive `@eN` references the agent will act on.
Fix:
* Extract the zero-width-space escape into a named, exported helper
`escapeEnvelopeSentinels(content)` in content-security.ts.
* Have `wrapUntrustedPageContent` call it (behavior unchanged on
that path — same bytes out).
* Import the helper in snapshot.ts and map it over `untrustedLines`
in the `splitForScoped` branch before pushing the BEGIN sentinel.
Tests: add a describe block in content-security.test.ts that covers
* `escapeEnvelopeSentinels` defuses BEGIN and END markers;
* `escapeEnvelopeSentinels` leaves normal text untouched;
* `wrapUntrustedPageContent` still emits exactly one real envelope
pair when hostile content contains forged sentinels;
* snapshot.ts imports the helper;
* the scoped-snapshot branch calls `escapeEnvelopeSentinels` before
pushing the BEGIN sentinel (source-level regression — if a future
refactor reorders this, the test trips).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
browse/src/content-security.tsdefines a trust boundary envelope around page content that gets handed to the LLM:The wrap path (
wrapUntrustedPageContent, the one used bytext/html/ full-page commands) already splices a zero-width space through the wordCONTENTwhenever those sentinels appear inside the content itself. That's the defense: a page that renders the literal sentinel string still goes through as visible text, but no longer matches the envelope grep the LLM anchors on.The scoped-token path in
browse/src/snapshot.ts(splitForScoped, used bysnapshotandresumefor scoped clients) builds the envelope by hand and skips that escape. It just does:So a page whose accessibility tree renders the literal
═══ END UNTRUSTED WEB CONTENT ═══closes the envelope early, and the attacker can forge a newINTERACTIVE ELEMENTS (trusted …)block with any@eNreference they want. The LLM consuming that output sees a fake trusted @ref that doesn't exist in the real ref map, and if it tries to click/fill it on the rendered page it lands on whatever element the attacker chose to alias.Reproduction (before the fix)
Stripped-down PoC — runs the two helper functions verbatim against the same hostile input:
Output shows
splitForScopedemitting two BEGIN and two END sentinels on the same string (envelope breached), whilewrapUntrustedPageContentemits exactly one of each (envelope intact).Fix
One change, applied in two small steps so each side stays easy to audit.
content-security.tsnow hasescapeEnvelopeSentinels(content: string): string— same two.replace()calls, no behavior change on the wrap path.wrapUntrustedPageContentcalls it internally.snapshot.tsimportsescapeEnvelopeSentinelsand maps it overuntrustedLinesin thesplitForScopedbranch before pushing the BEGIN sentinel.No changes to sentinel strings, no per-request nonce, no change to the scoped-ref map, no change to
wrapUntrustedPageContentoutput bytes for any non-hostile content. The scoped path now behaves exactly like the wrap path with respect to in-content sentinel escape.Sibling review
Greped
browse/src/for every emission of═══ BEGIN UNTRUSTED WEB CONTENT ═══:content-security.tswrapUntrustedPageContentcontentstringescapeEnvelopeSentinelssnapshot.tssplitForScopedbranchuntrustedLinesfrom accessibility treebrowse/src/No other call site emits the sentinel. Both paths now funnel untrusted text through the same escape helper.
Tests
browse/test/content-security.test.ts— a newEnvelope sentinel escapedescribe block with 6 cases:escapeEnvelopeSentinelsdefuses a BEGIN marker inside content.escapeEnvelopeSentinelsdefuses an END marker inside content.escapeEnvelopeSentinelsleaves normal text untouched (identity on non-sentinel lines).wrapUntrustedPageContentemits exactly one real envelope around hostile content that carries a forged BEGIN + END pair (regression for the already-protected wrap path).snapshot.tsimportsescapeEnvelopeSentinelsfrom./content-security.snapshot.tscallsescapeEnvelopeSentinelsbefore pushing the BEGIN sentinel (source-level lock; if a future refactor drops the escape or reorders the calls, the test trips before the diff reaches review).bun test browse/test/content-security.test.ts: 53 pass, 0 fail.Negative control
Reverting
browse/src/snapshot.ts+browse/src/content-security.tstoorigin/mainand rerunning the same file:escapeEnvelopeSentinelsimport fails first (not exported yet).snapshot.tsfail.Applying the fix: 53/53 pass.
What stayed the same
wrapUntrustedPageContentoutput bytes unchanged on any input that doesn't contain the sentinel.@eNref resolver, no change tosplitForScoped's public signature, no change to how scoped snapshots are routed through the CLI.SECURITY:section in the agent preamble) unchanged. The envelope it tells the LLM to trust is now genuinely one envelope on both code paths.Files
How to verify