You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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);
the same URL served statically with a <meta> CSP, no header (a host with no header control);
today's srcdoc frame plus a <meta> CSP — CSP policies compose (every policy must pass), so the inherited visor policy is tightened, not replaced;
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.open → null (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.
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
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.
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:/_sandbox/frame.html) whose response the bootstrap SW synthesizes, header CSP attached (spec reading: sandboxed-frame navigations are routed through HTTP fetch → SW matching);<meta>CSP, no header (a host with no header control);srcdocframe plus a<meta>CSP — CSP policies compose (every policy must pass), so the inherited visor policy is tightened, not replaced;srcdocwith nothing (control — the inheritance problem NOTES names).Plus: does the embedder's
frame-srcstop 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 policydefault-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>, CSSurl(), blobWorker,window.open, storage/cookie/IDB access,RTCPeerConnectionconstruction,WebAssembly.compileon bytes received bypostMessage, and agetBoundingClientRectread. 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)
wasm.compilew/'wasm-unsafe-eval'sandbox=allow-scripts, SW registered<meta>CSPsrcdoc+<meta>CSPsrcdoc, no meta (control)fetch,sendBeacon,<img>)source=static,fromServiceWorker=false. Controls: the same URL in an unsandboxed frame, or withallow-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-shippedsrcdocloader, independent of the self-hosting bar.<meta http-equiv=Content-Security-Policy>is fully enforced for everything tested, including'wasm-unsafe-eval'(its absence yieldsCompileError: ... blocked by CSPin both engines).srcdoc+<meta>is network-dead. The inheritedconnect-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.fetchrejected 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.RTCPeerConnectionconstructs and opens a data channel underdefault-src 'none'in both engines (nowebrtcdirective 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.allow-same-originalso gave:window.open→null(Chromium) / throwsInvalidAccessError(Firefox);navigator.serviceWorkeraccess throwsSecurityErrorin Chromium; blobWorkerblocked byworker-src(Firefox constructs then errors asynchronously).sendBeaconreturnstruein Chromium and is then blocked — don't read its return value as evidence.Self-navigation: embedder
frame-srcgoverns child-initiated navigationslocation.href = http://127.0.0.1:PORT/other.html(cross-origin)frame-srcviolation at embedderlocation.href = http://localhost:PORT/other.html(same origin, other path)<a href=…>+.click()srcdoc+meta frame, cross-origindefault-src 'self'fallback/other.htmlwas never requested in any row. Difference in aftermath: Chromium replaces the frame's document (secondloadevent, probe stops answering); Firefox leaves the original document running. Either way the embedder gets asecuritypolicyviolationevent namingframe-src, which is the kill trigger. Ruling for #5:frame-src(path-scoped for a real URL; thedefault-src 'self'fallback already coverssrcdoc) + 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)
iframetargetsrcdoc, sandboxedabout:srcdoc)IsolateSandboxedIframesis default-on in 143 and applies tosrcdoctoo — 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-processalone restored isolation for all three. Android's partial site isolation is the open cell (not measured).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.layoutReadcame back0in a few OOPIF runs — measured before first layout; timing artifact, not a CSP effect.)What this means
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.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.srcdocfor the loader.base32(HMAC(user-secret, app-id))under a wildcard). In Chromium desktop thesrcdocframe 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
MessagePort(the actual B placement).srcdocframes.'self'matching semantics for a real-URL opaque frame (avoided by hash-listing the loader).<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).