From 9e05b47d125dda9feaeb5120fff4fc17fc433696 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 20:50:41 -0400 Subject: [PATCH] Frame CSP: img-src admits data:, so the stylesheet's SVG checkboxes render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TodoMVC's checkboxes are data: SVG background images in its stylesheet; the frame's img-src blob: blocked them, so the toggles were invisible. A data: URL fetches nothing — its bytes are inline in the stylesheet that names it — so the frame stays network-dead. App-authored is still refused by the receiver policy. frame-network-dead now loads a data: image in the frame and fails if the CSP blocks it. --- docs/design.md | 3 ++- e2e/run.ts | 16 ++++++++++++++++ web/boot.ts | 9 +++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/design.md b/docs/design.md index 77b0a187..fb71957a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,7 +131,8 @@ there. - An opaque-origin `srcdoc` document carrying its own `` CSP (`default-src 'none'`; `script-src` the loader's hash plus `'wasm-unsafe-eval'`; `style-src`/`img-src`/`font-src`/`media-src - blob:` — the asset stylesheet is a `blob:`). CSP + blob:` — the asset stylesheet is a `blob:`; `img-src` also `data:`, for + the stylesheet's inline SVG backgrounds, which fetch nothing). CSP policies compose with the embedder's header policy, so the frame is network-dead regardless. `sandbox="allow-scripts allow-forms"`; `form-action 'none'`. The loader is a constant; everything variable diff --git a/e2e/run.ts b/e2e/run.ts index e4adc756..2fa4efdf 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -1260,6 +1260,22 @@ const scenarios: Scenario[] = [ const background = await todoFrame(page).locator("section.todoapp") .evaluate((el) => getComputedStyle(el).backgroundColor); eq(background, "rgb(255, 255, 255)", "the stylesheet did not apply"); + + // The checkboxes are `data:` SVG background images in that stylesheet + // (todomvc-app.css `.toggle + label`), which `img-src` must admit. A + // computed `background-image` reads the same whether or not CSP let + // the image load, so load a `data:` image in the frame and see. + const dataImageLoads = await todoFrame(page).locator("body").evaluate( + () => + new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = + "data:image/svg+xml,"; + }), + ); + check(dataImageLoads, "the frame's CSP blocks data: images"); }, }, diff --git a/web/boot.ts b/web/boot.ts index 331a9365..73c7f31c 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -343,7 +343,12 @@ function base64(bytes: ArrayBuffer): string { * cannot reach the network at all. Everything it renders comes down the * mutation stream or out of a `blob:` the parent minted, which is why * `style-src` allows `blob:` (the app bundle's stylesheet is an asset, so - * `` resolves to a blob URL). + * `` resolves to a blob URL). `img-src` also allows `data:`: the + * bytes of a `data:` URL are inline in whatever names it — here, the + * stylesheet's `background-image` SVGs — so nothing leaves the frame. An + * `` from the app is still refused, by the receiver + * policy (web/policy.ts: URL-kind attributes take asset handles only), not + * by this CSP. * * The hash covers the exact text between the tags INCLUDING the two * newlines, because that is what the browser hashes. @@ -358,7 +363,7 @@ async function frameSrcdoc(frameJs: string): Promise { "default-src 'none'", `script-src 'sha256-${base64(digest)}' 'wasm-unsafe-eval'`, "style-src blob:", - "img-src blob:", + "img-src blob: data:", "font-src blob:", "media-src blob:", "form-action 'none'",