Skip to content

test(journeys): red-probe every egress channel out of the render frame - #438

Merged
Yambr merged 7 commits into
docs/demo-walkthroughfrom
test/frame-egress-red-probes
Aug 12, 2026
Merged

test(journeys): red-probe every egress channel out of the render frame#438
Yambr merged 7 commits into
docs/demo-walkthroughfrom
test/frame-egress-red-probes

Conversation

@Yambr

@Yambr Yambr commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

The half of the render claim nothing was checking.

Group M proves the render works. Nothing proved it leaks nothing, and nothing proved it reads nothing — three independent claims. A frame that renders all thirteen formats, ships bytes to an attacker and holds a cookie jar passes every M test today.

Send side — ten channels, each actually attempted

fetch · XHR · sendBeacon · image beacon · form post · window.open · <base href> injection · same-frame self-navigation · Worker · prefetch

They do not fall to one directive, which is why they are enumerated:

  • base-uri and form-action never inherit from default-src — a policy that sets default-src 'none' and stops there leaves both open.
  • A same-frame navigation (location = attacker-url?data) is not a fetch, so no fetch directive touches it, and allow-top-navigation governs the top window rather than the frame's own.

The assertion is on what an attacker-controlled sink saw, not on whether the JS threw: a fetch() rejects for a blocked request and an unreachable host alike.

Read side — eight surfaces

document.cookie · localStorage · sessionStorage · indexedDB · parent DOM · parent location · top location · own origin

ADR-0026 states the frame cannot read a cookie, a token, another artifact, or anything in the embedder. The origin probe is the load-bearing one: it asserts origin === "null". If someone adds allow-same-origin, the read probes go red — but the egress probes would not, since a same-origin frame under the same CSP still cannot fetch. That one assertion is what stops the other nineteen from measuring an ordinary iframe.

Anti-vacuity

control catches
n0 — the sink records a request it definitely received a mis-bound sink reading as ten clean channels
n2 — every channel reaches the sink from an unpoliced about:blank a payload that silently stopped firing, whose probe then proves nothing
absent render frame FAILS probing the wrong context — see below

Three defects found in this file while writing it

Probing the wrong frame. _render_frame matched the pane — the SPA origin — because the render frame does not exist yet. Every probe would have asserted the sandbox is closed while measuring a different context, and gone green, because the SPA origin may refuse those channels under its own policy. Now it descends to a child frame and fails loudly with the frame list when there is none.

A 35-second sink. HTTPServer.server_bind calls getfqdn(), which stalls on this host — long enough for every probe to time out and report a leak-free channel it never reached.

Self-navigation killed the run. location.href destroys the execution context, which is the attempt succeeding; evaluate raised and failed the whole test instead of recording one channel. Now only that message is swallowed — a payload typo or a dead browser still propagates.

jsdom was measured and rejected as the venue

Not assumed — run. Under jsdom a blocked fetch and an unreachable host are the same TypeError: fetch failed, and the result is byte-identical with a default-src 'none' meta present and absent. Probes there would be green against no policy at all. These run in real Chromium under the existing OCU_BROWSER_E2E gate, where gate-set-but-no-chromium fails rather than skips.

State

20 tests collect; n0 passes in 0.55 s; the browser probes skip loudly without a stand. Mutation-checked: a sink that stops recording reds n0, and so does a saw() that always returns False.

They run against the frame once it exists. Landing them first means the renderers move into a box that is already proven, and adding a format stops being a security decision.

Group M proves the render works. Nothing proved it leaks nothing, and those are
independent claims — a frame that renders all thirteen formats and quietly ships
bytes to an attacker passes every M test.

Ten channels, each attempted for real inside the frame: fetch, XHR, sendBeacon,
image beacon, form post, window.open, `<base href>` injection, same-frame
self-navigation, Worker, prefetch. They do not fall to one directive: `base-uri`
and `form-action` never inherit from `default-src`, and a self-navigation is not
a fetch at all, so no fetch directive touches it.

Each probe asserts on what an attacker-controlled sink SAW, not on whether the
JS threw. A fetch rejects for a blocked request and an unreachable host alike;
only the sink separates "policy refused it" from "it never got there".

Two anti-vacuity controls, because a suite that reports "no leak" for a channel
it never exercised is worse than no suite. n0 proves the sink records at all —
without it a mis-bound sink reads as ten clean channels. n2 drives every channel
from an unpoliced about:blank and reds on any that cannot reach the sink even
there, which is what catches a payload that silently stopped working.

jsdom was measured and rejected as the venue: a blocked fetch and an unreachable
host are the same `TypeError: fetch failed`, and the result is byte-identical
with a `default-src 'none'` meta present and absent. Probes there would be green
against no policy at all, so these run in real Chromium under the existing
OCU_BROWSER_E2E gate, where gate-set-but-no-chromium fails rather than skips.

The sink binds without a reverse lookup. `HTTPServer.server_bind` calls
`getfqdn()`, which cost 35s per sink on this host — measured, and enough for
every probe to time out and report a leak-free channel it never reached.

Mutation-checked: a sink that stops recording reds n0, and so does a marker
match that always returns False.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • next/v1

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e75e086d-27ea-4378-879c-a41d0bdf1663

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

widemoat-ai and others added 6 commits August 12, 2026 20:23
… design

`location.href = attacker` is the attempt, and it tears down the execution
context Playwright is evaluating in. `evaluate` then raises, which failed the
whole run instead of recording one channel's outcome — and in n2 it would have
aborted the loop before the channels that follow it were ever exercised.

The raise carries no information here: the verdict comes from what the sink saw,
not from whether the call returned. `_attempt` swallows exactly the
context-destroyed message and re-raises everything else, so a typo in a payload
or a dead browser still fails loudly rather than reading as a clean channel.

Verified by construction: a navigation error is swallowed, a ReferenceError in
the payload and a closed-browser error both re-raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_render_frame` matched any frame whose URL contained the pane's host, which
today is the pane itself — the SPA origin. The render frame does not exist yet;
it arrives with the ADR-0026 substrate. So every probe would have run in the SPA
origin and asserted the sandbox is closed while measuring a different context
entirely.

The failure mode is the dangerous direction: the SPA origin may well refuse
these channels under its own policy, so the suite would have gone green without
a sandbox existing at all. That is the exact shape of green this file was
written to prevent, in the file itself.

The lookup now descends to a CHILD of the pane and matches the renderer-document
route (or about:srcdoc / blob:, depending on how it ends up served). When no
such child exists the probe FAILS and prints the frames it did see, rather than
passing on the wrong context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…issed

A dead-channel list names which payloads failed; it does not say why. The sink's
actual hits distinguish 'the payload never fired' from 'it fired at the wrong
URL and the marker did not match' — two different fixes, and the second reads as
the first without this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0026 says the frame "cannot read a cookie, a token, another artifact, or
anything in the embedder". That is a different property from "cannot send", and
nothing was checking it either — a frame with a cookie jar and a same-origin
handle on its parent passes every egress probe in this file.

Eight surfaces: document.cookie, localStorage, sessionStorage, indexedDB, the
parent's DOM, parent location, top location, and the frame's own origin.

The origin probe is the load-bearing one. It asserts the frame reports
`origin === "null"`, because an opaque origin is what closes all seven others.
If someone adds `allow-same-origin`, every probe in this file quietly starts
measuring an ordinary same-origin iframe instead of a sandbox — and the seven
read probes would go RED there, but the ten egress probes would not, since a
same-origin frame under the same CSP still cannot fetch. That single assertion
is what keeps the rest honest.

Absence of the render frame FAILS rather than passing: in the SPA origin every
one of these reads succeeds by design, so a probe that landed there would report
the opposite of the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t it misses

Three fixes from an adversarial pass that ran the payloads in a real Chromium.

The control was inoperative. n2 fired every channel from `about:blank` — a null
origin in an insecure context — at a sink on 127.0.0.1, and Chromium's Private
Network Access refuses that hop wholesale:

  blocked by CORS policy: The request client is not a secure context and the
  resource is in more-private address space `loopback`

So every channel landed in `dead[]` and the sole anti-vacuity control failed for
a reason unrelated to the payloads. The sink now serves a `/__control__` page,
and the control runs from that real http origin at the same address-space
privilege. From there all ten payloads were confirmed live — including the three
I doubted: an input-less form does POST, prefetch does fire headless, and a
blob: Worker constructs and its inner fetch lands.

Fixed sleeps are gone. Both families now poll the sink against one shared
budget: a hit ends the wait immediately, absence is only declared at the
deadline, and a channel is never given less time to leak than the control gave
it to prove it can.

The suite now states what it does NOT test. `frame.evaluate` runs in a CDP
isolated world, where code executes even in a frame whose `script-src 'none'`
would stop a page script starting. These probes measure the network layer given
running code; they do not exercise the earlier leg. A frame that lost that leg
entirely would still pass every probe here, and the docstring says so rather
than letting a green read as the whole invariant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I claimed testing "a page script cannot start" needed the render substrate and
documented it as out of reach. That was wrong: the isolation primitives
reproduce standalone, and an adversarial pass built and ran the probe to show
it. The script arrives as BODY CONTENT the way a hostile artifact would, in a
sandboxed srcdoc frame with no `allow-scripts`, and the browser refuses to
execute it:

  Blocked script execution in 'about:srcdoc' because the document's frame is
  sandboxed and the 'allow-scripts' permission is not set.

n1 cannot see this leg at all — `frame.evaluate` runs in a CDP isolated world,
so it executes where a page script could not start, and n1 therefore measures
the network layer given running code.

The control is the same body in an UNSANDBOXED frame, which must leak. Without
it a green could equally mean the payload never worked, the sink was deaf, or
srcdoc escaping mangled the script — none distinguishable from "the sandbox
held".

What this still does not cover, and the code says so: the product's own wiring.
If the shipped frame carries the CSP or the sandbox attribute wrong, only a
probe against that frame catches it. This closes "the primitives block
script-execution egress"; n1's frame lookup closes "our frame is built from
those primitives".

Also waives the semgrep `dynamic-urllib-use-detected` finding at the control's
urlopen, per-line: the URL is built from a port this process just bound on
127.0.0.1, and the file:// hazard the rule warns about needs an
attacker-supplied scheme, for which there is no path here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yambr

Yambr commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Ran and mutation-checked in a real Chromium, not just written.

n4 — the leg n1 cannot reach — passes, and is bound to its subject

The probe loads a hostile body carrying an inline <script> into a sandboxed srcdoc frame with no allow-scripts. Chromium refuses to execute it:

Blocked script execution in 'about:srcdoc' because the document's frame is
sandboxed and the 'allow-scripts' permission is not set.

2 passed in 4.15s. Two greens are exactly what needs a red-probe, so both directions were mutated:

mutation result
give the sandboxed frame allow-scripts 1 failed — the leg reds when the sandbox is weakened
neuter the payload to void 0 1 failed — the control reds when the body stops leaking

So a green here cannot come from a dead payload, a deaf sink, or srcdoc escaping mangling the script — the three ways this could have looked like "the sandbox held".

I was wrong about this being unbuildable

I had documented the script-execution leg as out of reach without the render substrate, reasoning that the script must arrive as body content through the real render path. The first half was right; the second was not. The isolation primitives reproduce standalone, so the probe exists now rather than waiting.

What it still does not cover is written into the code: this closes "the primitives block script-execution egress". It does not close "our frame is built from those primitives" — if the shipped substrate carries the CSP or the sandbox attribute wrong, only n1 against that frame catches it, and n1 fails rather than skips until the frame exists.

Gates

SAST — semgrep is green after a per-line waiver on the control's urlopen: the URL is built from a port this process just bound on 127.0.0.1, and the file:// hazard the rule warns about needs an attacker-supplied scheme, for which there is no path here. All checks pass; mergeStateStatus: CLEAN.

@Yambr
Yambr merged commit 1735f0f into docs/demo-walkthrough Aug 12, 2026
11 checks passed
@Yambr
Yambr deleted the test/frame-egress-red-probes branch August 12, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants