Skip to content

Spike: app-frame CSP without a dedicated origin — srcdoc + <meta> is network-dead, SWs never see sandboxed navigations, process-isolation matrix #142

Description

@lannbot

Spike, executed 2026-09-05. Code (~330 lines: server.ts, frame-script.js, run.ts) lives at /tmp/opencode/sandbox-frame-spike/ on the machine that ran it — a tempdir, not archived; the JSON result files are there too. Everything below is measured with Playwright 1.57 driving Chromium 143.0.7499.4 (full build, new headless; the headless shell is noted where it differed) and Firefox 144.0.2.

The question

The brainstorm behind this ("design B": app wasm inside the frame, real DOM behind WebIDL-mirror imports, frame realm network-dead by platform means) was rejected on the premise that it needs a dedicated sandbox origin — too high a bar for turnkey static self-hosting. The spike asks what the frame's confinement actually requires from the host, by testing four ways of giving the app frame a default-src 'none'-class CSP that differs from the visor's:

  1. a real same-origin URL (/_sandbox/frame.html) whose response the bootstrap SW synthesizes, header CSP attached (spec reading: sandboxed-frame navigations are routed through HTTP fetch → SW matching);
  2. the same URL served statically with a <meta> CSP, no header (a host with no header control);
  3. today's srcdoc frame plus a <meta> CSP — CSP policies compose (every policy must pass), so the inherited visor policy is tightened, not replaced;
  4. srcdoc with nothing (control — the inheritance problem NOTES names).

Plus: does the embedder's frame-src stop child-initiated self-navigation; is 'wasm-unsafe-eval' needed via <meta>; and is the frame in its own process (spin test: the frame busy-loops 2 s, the embedder measures its max timer gap).

Setup

Deno server on port 0. Embedder page / carries the visor-shaped policy default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'; frame-src http://localhost:PORT/_sandbox/frame.html (path-scoped). Frame CSP under test: default-src 'none'; script-src 'sha256-<probe>' ['wasm-unsafe-eval']; style-src 'unsafe-inline'. The probe inside the frame tries, with a per-run token in every path: fetch, WebSocket, sendBeacon, <img src>, CSS url(), blob Worker, window.open, storage/cookie/IDB access, RTCPeerConnection construction, WebAssembly.compile on bytes received by postMessage, and a getBoundingClientRect read. The server logs every request path, so "blocked" means the request never left the browser, not "JS saw an error".

Results

Confinement (identical in Chromium and Firefox)

frame shape who served it requests reaching server storage/cookie/IDB wasm.compile w/ 'wasm-unsafe-eval' w/o
real URL, sandbox=allow-scripts, SW registered static file, SW bypassed 0 throw ok CompileError
real URL, no SW, <meta> CSP static 0 throw ok
srcdoc + <meta> CSP visor bundle 0 throw ok
srcdoc, no meta (control) visor bundle 3 (fetch, sendBeacon, <img>) throw
  • Service workers never see a sandboxed (opaque-origin) frame's navigation. Both engines: source=static, fromServiceWorker=false. Controls: the same URL in an unsandboxed frame, or with allow-same-origin, is served by the SW. So "the verifying SW attaches the header" is dead — a real-URL skeleton would be served raw by the origin, outside Release integrity: content-addressed assets, the tiny mutable bootstrap, signed manifests, third-party monitoring #3's verified set, and its CSP would be whatever the host sends. That is a regression against today's visor-shipped srcdoc loader, independent of the self-hosting bar.
  • <meta http-equiv=Content-Security-Policy> is fully enforced for everything tested, including 'wasm-unsafe-eval' (its absence yields CompileError: ... blocked by CSP in both engines).
  • srcdoc + <meta> is network-dead. The inherited connect-src 'self' no longer matters because the added policy also has to pass. Zero requests reached the server; wasm compiles (the visor's own policy carries 'wasm-unsafe-eval' for polyengine, and the meta repeats it). The loader stays inline in the visor's bundle, i.e. inside Release integrity: content-addressed assets, the tiny mutable bootstrap, signed manifests, third-party monitoring #3's verified set. No new URL, no header control, no second domain.
  • The control confirms NOTES' inheritance concern is real, not theoretical: fetch rejected in JS (CORS, opaque origin) but the request was sent, as were the beacon and the image. Under the current frame, the applier's URL validation is the only thing standing between an app string and the network.
  • RTCPeerConnection constructs and opens a data channel under default-src 'none' in both engines (no webrtc directive ships anywhere; MDN compat data has no entry). Irrelevant when only framework code runs in the frame; disqualifying for any app-JS-in-frame tier.
  • Sandbox without allow-same-origin also gave: window.opennull (Chromium) / throws InvalidAccessError (Firefox); navigator.serviceWorker access throws SecurityError in Chromium; blob Worker blocked by worker-src (Firefox constructs then errors asynchronously). sendBeacon returns true in Chromium and is then blocked — don't read its return value as evidence.

Self-navigation: embedder frame-src governs child-initiated navigations

attempt (from inside the frame) Chromium Firefox
location.href = http://127.0.0.1:PORT/other.html (cross-origin) blocked, frame-src violation at embedder same
location.href = http://localhost:PORT/other.html (same origin, other path) blocked (path-scoped source works) same
<a href=…> + .click() blocked same
srcdoc+meta frame, cross-origin blocked via embedder's default-src 'self' fallback same

/other.html was never requested in any row. Difference in aftermath: Chromium replaces the frame's document (second load event, probe stops answering); Firefox leaves the original document running. Either way the embedder gets a securitypolicyviolation event naming frame-src, which is the kill trigger. Ruling for #5: frame-src (path-scoped for a real URL; the default-src 'self' fallback already covers srcdoc) + kill-on-violation closes the self-navigation channel on both engines.

Process isolation (spin test, 2 s busy-loop in the frame; embedder max timer gap)

frame shape Chromium full, default flags Chromium headless shell Firefox, Fission forced on Firefox as Playwright ships
same-site real URL, sandboxed 27–388 ms, own iframe target 2001 ms 2001 ms 2000 ms
cross-site real URL 26–64 ms, own target 2001 ms 41 ms 2001 ms
srcdoc, sandboxed 24–88 ms, own target (about:srcdoc) 2001 ms 2004 ms 2000 ms
  • Chromium: IsolateSandboxedIframes is default-on in 143 and applies to srcdoc too — with site isolation active, today's frame shape is already out of process, and removing the <iframe> is a preemptive kill. The headless shell runs without site isolation (all three shapes wedged the embedder) — an artifact of that binary, not a browser fact; --site-per-process alone restored isolation for all three. Android's partial site isolation is the open cell (not measured).
  • Firefox: only cross-site frames get a process. No sandboxed-iframe isolation exists, so a srcdoc (or same-origin) frame shares the visor's process and thread; a spinning app wedges the tab. This is App guests run in workers: per-app blast zone, preemptive kill #45's territory: in Firefox, honest kill needs either a worker or a cross-site frame origin.
  • (Noise: layoutRead came back 0 in a few OOPIF runs — measured before first layout; timing artifact, not a CSP effect.)

What this means

  1. The dedicated sandbox origin is not required for confinement. Confinement = import set (primary) + a frame whose own CSP is default-src 'none'. srcdoc + one <meta> tag delivers the latter on the visor's own terms — visor-shipped loader, SW-verified, no host header, no DNS. The self-hosting objection to B goes away; what remains of B is the placement question (wasm inside the frame with DOM imports bound to real objects), which this spike did not touch.
  2. Do it regardless of B. Even under the current op-protocol design, the meta CSP turns the applier's URL validation from sole defense into defense-in-depth: the control row shows what an applier bug leaks today. ~One line in visor/frame/frame.html. Rulings for App-frame sandboxing: a ruling table per sandbox flag and CSP directive; residual channels; anti-spoofing chrome #5 become "backstopped by the frame's own policy", which is what App execution model: no app JS - components drive a curated DOM surface, the UI frame runs only framework code #16 wanted the table demoted to.
  3. The SW-synthesizes-headers idea is dead in both engines, and any real-URL skeleton is outside Release integrity: content-addressed assets, the tiny mutable bootstrap, signed manifests, third-party monitoring #3's verification — one more reason to prefer srcdoc for the loader.
  4. A second domain buys exactly one thing: process isolation in Firefox (and app↔app separation if per-app labels, e.g. base32(HMAC(user-secret, app-id)) under a wildcard). In Chromium desktop the srcdoc frame already has its own process. This is a responsiveness/side-channel tier for the Home origin contract: required headers, MIME, service-worker scope - and an origin conformance checker #2 checker to report, not a confinement tier — and App guests run in workers: per-app blast zone, preemptive kill #45's worker split remains the engine-independent answer to a spinning app.

Not tested / open

  • polyengine instantiating inside the frame with DOM imports bound locally and the rest proxied over a MessagePort (the actual B placement).
  • Android Chromium site-isolation behaviour for srcdoc frames.
  • 'self' matching semantics for a real-URL opaque frame (avoided by hash-listing the loader).
  • Whether <meta> CSP timing (applies from parse point) matters for a loader we control entirely — it should not, the meta is the first child of <head>.

Refs: #5 (ruling table), #16 (execution model), #45 (workers / kill), #4 (origin topology), #3 (release integrity).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions