Repository navigation
feat(csp): Worker-rendered pages name their inline scripts by hash - #925
Merged
Merged
Conversation
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.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
aadhar-sh | 3b476a4 | Sep 25 2026, 06:13 PM |
Contributor
Wire-size diff vs merge base
Brotli q11, the quality the edge serves. Every number here is deterministic: Client assetsNo change. 27 files, 91.57 KiB Brotli total. PagesNo change. 58 files, 595.82 KiB Brotli total. Worker bundle255.37 KiB → 257.03 KiB gzip (+1.66 KiB, +0.7%) Largest module deltas (raw bytes in bundle):
Dictionary transportFamily dictionary acquisition: no change, 14.41 KiB q11.
|
oddharsh
enabled auto-merge (squash)
September 25, 2026 18:12
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.lunaPagealready holds the whole document as one string before it builds the Response. So it now hashes that document's inline scripts and sends a hashedscript-srcwith 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
/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.subtleis async and this Worker has nonodejs_compat. MakinglunaPageasync would change the return type for 20 callers just to add one header. Checked byte for byte againstnode:cryptoon 128 inputs, including non-ASCII and padding boundaries.lib/inline-csp.ts: the scanner. It only ever reads our own output: every valuelunaPageinterpolates is escaped, so a<scriptcan 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, soif (i<n) el.onload = finside a script isn't mistaken for an attribute.lib/csp-policy.ts: the policy strings, moved out ofsecurity.tsinto a leaf module.chrome.tsimportingsecurity.tswould have created an import cycle throughspeculation.tsandledger.ts.Verification
csp:sweepover 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 ascript-src-elemblock, so the clean sweep is a real measurement.contract-worker-pages-hash-their-inline-scripts:tools/lib/csp-scan.ts) on rendered pages and seven adversarial documents (data blocks,data-srclook-alikes, type spellings, near-miss close tags, empty scripts, handler-shaped code in script bodies, duplicates);application/json, and a strict</script>close) each fail it.Follow-ups
/coffeeis the next candidate. It needs its own copy of the scan insidecal/, for the gotcha 16 reason.