Skip to content

feat(csp): Worker-rendered pages name their inline scripts by hash - #925

Merged
oddharsh merged 2 commits into
mainfrom
claude/worker-csp-hashes
Sep 25, 2026
Merged

oddharsh merged 2 commits into
mainfrom
claude/worker-csp-hashes

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

What changes

Every page the Worker renders at request time shipped script-src 'self' 'unsafe-inline', because build step 7c's hash map only covers staged documents. That was the open follow-up in gotcha 17, which planned a per-response nonce.

lunaPage already holds the whole document as one string before it builds the Response. So it now hashes that document's inline scripts and sends a hashed script-src with the bytes. A cached render (cachedRender) stores that header beside the body it describes, so the two can't drift apart.

Coverage, measured against a locally built Worker

now hashed still loose, and why
/ledger, /around, /reading, /inbox, /lens/census, /garage/dyno, /search?q=, /run?cmd=, /updates, /bot, /rn/admin, the tool pages as HTML (/dict, /cache, /encode, /radar, /finger, /agent-ready), plus the dev fallbacks for built pages /coffee: cal renders its own templates and can't import the site tree (gotcha 16)
/serendipity: composes its own policy
/lens?url= framed view: composes its own policy (a caller's policy still wins)

How

  • lib/sha256.ts: a synchronous SHA-256. crypto.subtle is async and this Worker has no nodejs_compat. Making lunaPage async would change the return type for 20 callers just to add one header. Checked byte for byte against node:crypto on 128 inputs, including non-ASCII and padding boundaries.
  • lib/inline-csp.ts: the scanner. It only ever reads our own output: every value lunaPage interpolates is escaped, so a <script can only come from markup a page author wrote. It fails open to the old policy on anything a hash can't cover (event-handler attributes, javascript: URLs, srcdoc). Script and style bodies are stripped before that check, so if (i<n) el.onload = f inside a script isn't mistaken for an attribute.
  • lib/csp-policy.ts: the policy strings, moved out of security.ts into a leaf module. chrome.ts importing security.ts would have created an import cycle through speculation.ts and ledger.ts.

Verification

  • Real Chrome: csp:sweep over 19 Worker-rendered documents reports 0 blocked scripts and 0 page errors. The control loads the same page with the hashes stripped from the header, and reports a script-src-elem block, so the clean sweep is a real measurement.
  • New contract test contract-worker-pages-hash-their-inline-scripts:
    • checks the scanner against the build's HTMLRewriter parser (tools/lib/csp-scan.ts) on rendered pages and seven adversarial documents (data blocks, data-src look-alikes, type spellings, near-miss close tags, empty scripts, handler-shaped code in script bodies, duplicates);
    • checks the fail-open cases;
    • checks that the security wrapper keeps the page's policy and that a caller's own policy wins.
    • Two deliberate breaks (hashing application/json, and a strict </script> close) each fail it.
  • Cost: about 84 µs per scan on a 28 KB page under node (V8), under 1% of the Workers Free CPU budget. Cached pages pay it once per cache fill.
  • Gates: build, lint, typecheck and the route oracle (178/178) are clean, and the full suite passes 914/914.

Follow-ups

/coffee is the next candidate. It needs its own copy of the scan inside cal/, for the gotcha 16 reason.

Every page the Worker rendered at request time shipped script-src 'self'
'unsafe-inline', because build step 7c's hash map only knows staged
documents. That was gotcha 17's open follow-up, and the plan there was
a per-response nonce.

lunaPage holds the whole document as one string before it builds the
Response, so it can hash its own inline scripts and send the policy
with the bytes. A cached render stores the header beside the body it
describes. lib/inline-csp.ts does the scan and lib/sha256.ts the hash,
synchronously: crypto.subtle is async and there is no nodejs_compat.

It fails open to the loose policy on anything a hash cannot cover
(event-handler attributes, javascript: URLs, srcdoc), so a false alarm
costs nothing that shipped before. The contract test holds the scanner
to the build's HTMLRewriter parser on rendered pages and on adversarial
markup, and two deliberate breaks each fail it.

Measured against a locally built Worker: 16 routes now send hashed
script-src (/ledger, /around, /reading, /inbox, /lens/census,
/garage/dyno, /search?q=, /run?cmd=, /updates, the tool pages and
more). csp:sweep reads 19 documents with 0 blocked scripts in Chrome,
and its control (same page, hashes stripped) reports script-src-elem.
The scan costs about 84 us on a 28 KB page.

The CSP strings move to lib/csp-policy.ts, a leaf module, because
chrome.ts importing security.ts would close a cycle through
speculation.ts and ledger.ts.

Still loose, and not lunaPage pages: /coffee, /serendipity, and the
/lens?url= framed view, which composes its own policy.
@github-actions github-actions Bot added area: docs Runbooks and repository prose area: tools Developer tooling and the contract suite area: worker The site Worker: routing, rendering, bindings type: feat New capability or surface labels Sep 25, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh 3b476a4 Sep 25 2026, 06:13 PM

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

3698d3ec4046a7897079389101c72440255f25ed → 64b3d0e3f481f153651f25773bf6d65dd78238f9

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

No change. 27 files, 91.57 KiB Brotli total.

Pages

No change. 58 files, 595.82 KiB Brotli total.

Worker bundle

255.37 KiB → 257.03 KiB gzip (+1.66 KiB, +0.7%)

Largest module deltas (raw bytes in bundle):

file base head Δ
.build/src/worker/lib/sha256.ts — 1.74 KiB new
.build/src/worker/lib/inline-csp.ts — 0.77 KiB new
.build/src/worker/lib/csp-policy.ts — 0.39 KiB new
.build/src/worker/lib/security.ts 3.07 KiB 2.68 KiB -0.39 KiB (-12.6%)
.build/src/worker/lib/chrome.ts 4.00 KiB 4.06 KiB +0.06 KiB (+1.6%)

Dictionary transport

Family dictionary acquisition: no change, 14.41 KiB q11.

pd/: no change. 170 deltas, 496.44 KiB total.

@oddharsh
oddharsh enabled auto-merge (squash) September 25, 2026 18:12
@oddharsh
oddharsh merged commit a30b8d4 into main Sep 25, 2026
14 checks passed
@oddharsh
oddharsh deleted the claude/worker-csp-hashes branch September 25, 2026 18:15
oddharsh added a commit that referenced this pull request Sep 25, 2026
… an island (#929)

The /serendipity dashboard rendered per request behind a 60 s edge
cache. It shipped at the edge's q4 brotli with no dcz delta and no
ETag, and with script-src 'unsafe-inline', since it composes its own
policy and #925's lunaPage hashing never reached it. It was the
largest page left in that state: 15,258 B on the wire, 12 KB of it CSS.

build.ts step 5b now bakes the shell: the CSS, the toolbar and the
filter script. The pool's count line and cards arrive from
/serendipity/events.html through lib/island.ts. The island is
cookie-free and edge-cached for the same 60 s, and every mutation
evicts it the way it evicted the cached dashboard. A flash ?msg= view
still renders live and whole for the person who just acted.

The built page keeps serendipity's own CSP, since its img-src admits
the cover proxy's https fallback, with the build's three hashes in
place of 'unsafe-inline'. Its headers go in through serveStaticPage:
withSerendipitySecurityHeaders rebuilds the Response from an object
init, which would drop encodeBody on the precompressed body (gotcha 13).

Measured against production: shell 5,260 B at q11 plus ~7,350 B of
cards, so a first visit is about 12.6 KB (-17%) and a return visit
pays only the cards.

Also:
- The dashboard no longer mints a uid cookie on a plain view. Only
  /contribute and the POSTs use it, and they still mint it.
- serendipity's CSS ended with a stray top-level "}". Browsers skip it,
  and the build's Lightning CSS pass refused it on the page's first bake.
- The filter script re-reads the cards on every apply and re-runs when
  the island lands; the toolbar stays in the shell, so a search typed
  before the swap survives it.
- The layout-shift lab's control went unseen here, because .window >
  .body is a flex row on this shell. It now tries the content column
  first, and the control is seen on both shells.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: docs Runbooks and repository prose area: tools Developer tooling and the contract suite area: worker The site Worker: routing, rendering, bindings type: feat New capability or surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant