test(journeys): red-probe every egress channel out of the render frame - #438
Conversation
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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
… 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>
|
Ran and mutation-checked in a real Chromium, not just written. n4 — the leg n1 cannot reach — passes, and is bound to its subjectThe probe loads a hostile body carrying an inline 2 passed in 4.15s. Two greens are exactly what needs a red-probe, so both directions were mutated:
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 unbuildableI 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
|
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 · prefetchThey do not fall to one directive, which is why they are enumerated:
base-uriandform-actionnever inherit fromdefault-src— a policy that setsdefault-src 'none'and stops there leaves both open.location = attacker-url?data) is not a fetch, so no fetch directive touches it, andallow-top-navigationgoverns 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 originADR-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 addsallow-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
about:blankThree defects found in this file while writing it
Probing the wrong frame.
_render_framematched 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_bindcallsgetfqdn(), 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.hrefdestroys the execution context, which is the attempt succeeding;evaluateraised 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 adefault-src 'none'meta present and absent. Probes there would be green against no policy at all. These run in real Chromium under the existingOCU_BROWSER_E2Egate, 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.