Automations and webhooks for the AT Protocol airglow.run

fix(security): stop serving one CSP nonce to every visitor of edge-cached pages #93

Closed tyb.there-hugo.dev
Closed opened by tyb.there-hugo.dev

Anonymous pages cached at the edge (setPageCache, e.g. / with public, s-maxage=300, stale-while-revalidate=3600, /automations and /sync) carry the per-request CSP nonce from lib/security-headers.ts in both the header and the inline <script nonce> tags, so Cloudflare hands the same nonce to every visitor until the entry expires. A nonce anyone can read off a public response no longer stops an injected inline script on that page, which is the protection script-src 'nonce-...' is there for.

How I know: on 2026-11-02 I fetched https://airglow.run/ three times with Node's fetch over ~20 s; every response had cf-cache-status: EXPIRED/HIT and the same script-src 'self' 'nonce-9XlfwwpW/oKlOLeOyTtIvQ==', and the body's inline scripts used that nonce. Uncached pages (404s, cf-cache-status: DYNAMIC) got a fresh nonce each time.

Where to start: setPageCache in lib/http.ts and the nonce in lib/security-headers.ts. Options: hashes ('sha256-...') for the fixed inline scripts (theme bootstrap, speculation rules) instead of a nonce on cacheable pages, moving those scripts to static files, or not caching pages that carry a nonce.

Found by the loop while working on #69, rkey 69, branch loop/69, merged onto main as 9564625f86f1.

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreignki3umgnhdkibwugs2nt7riz3ym6jpv4ewnjjchshbq7zrcqnee

Working on this, attempt 1.

loop:claim run=contribute-20261102-215104-93 branch=loop/93 attempt=1 issue=93

Attempt 1 was stopped; it does not count.

loop:failed run=contribute-20261102-215104-93 attempt=1 interrupted=1

Working on this, attempt 1.

loop:claim run=contribute-20261103-125854-93 branch=loop/93 attempt=1 issue=93

fix(security): allow inline scripts by hash instead of a per-request CSP nonce

Edge-cached anonymous pages (/, /automations, /sync) carried the per-request CSP nonce in both the header and their inline <script nonce> tags, so Cloudflare served one readable nonce to every visitor until the entry expired. The CSP now allows the app's fixed inline scripts by their SHA-256 hashes, and no page carries a nonce: the policy is the same on every response, so a cached copy holds nothing a visitor could reuse to run an injected script.

What changed #

  • app/inline-scripts.ts (new) holds every inline script body the app ships: the theme bootstrap and the speculation rules (moved out of _renderer.tsx unchanged) and the hero rays bootstrap (re-exported). INLINE_SCRIPTS is the list script-src allows.
  • lib/security-headers.ts computes 'sha256-...' sources for that list once at load (scriptHashSource) and sends one constant CSP; the nonce generation and c.set("cspNonce") are gone, and so is cspNonce in app/global.d.ts.
  • _renderer.tsx and routes/index.tsx render their <script> tags without a nonce attribute. The JSON-LD block gets no hash: a type="application/ld+json" script is a data block the browser never runs, so CSP does not apply to it.
  • Comments that called the CSP nonce-based (app/client.ts, HeroRays/bootstrap.ts) say hash now.

Verified #

  • pnpm run check and pnpm run test pass (186 files, 2770 tests); pnpm run build succeeds.
  • lib/security-headers.test.ts: the CSP lists a hash for every entry in INLINE_SCRIPTS, has no 'nonce-, is identical across two requests, and scriptHashSource reproduces the CSP2 spec's worked example (alert('Hello, world.'); → 'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng=').
  • app/routes/index.test.tsx now mounts securityHeaders() and renders the anonymous, edge-cached /: it finds the three executable inline scripts in the HTML (theme bootstrap, speculation rules, rays bootstrap), hashes the exact text of each, and checks that hash is in the response's CSP; the HTML contains no nonce at all.

Acceptance #

Decisions the owner can reverse #

  • Hashes everywhere rather than hashes only on cacheable pages: no inline script is dynamic, so a nonce had nothing left to cover and one policy is simpler. A future inline script must be added to INLINE_SCRIPTS (the landing test fails if / gains one that is not).
  • The rays bootstrap hash is allowed site-wide, not only on /; it is the app's own script, so allowing it elsewhere opens nothing.
  • Not caching pages that carry a nonce (the issue's third option) was rejected: it would cost the edge cache on / for no gain over hashes.

Owner check after deploy #

  • Load https://airglow.run/ and a signed-in page with the console open and confirm no CSP violations. If a Cloudflare feature injects its own inline script (Rocket Loader, JavaScript detections), it used to be able to borrow the nonce and now would be blocked; I could not see which features are enabled on the zone. The analytics beacon is external and still allowed by origin.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/93

loop:pushed run=contribute-20261103-125854-93 sha=c8467627da7b8c0507e5d39594210a7cc755d488

Verdict: merge. The branch takes the issue's first option: the CSP's script-src allows the app's three fixed inline scripts (theme bootstrap, speculation rules, hero rays bootstrap) by SHA-256 hash, and the nonce is gone from the header and from every body. So /, /automations, /sync and /pricing no longer hand visitors a shared, readable nonce, and 'unsafe-inline' stays out of script-src. The check, the tests and the build all pass on c8467627.

What I checked and how:

  • pnpm run check passes. pnpm run test passes: 186 files, 2770 tests. pnpm run build succeeds.
  • Hunted for inline scripts myself (<script, inline handlers, Suspense/streaming, HonoX Script/islands). The only inline scripts are the three in INLINE_SCRIPTS plus the JSON-LD data block, which CSP does not govern. HonoX's Script emits an external src, which 'self' covers. No cspNonce reader is left in the code. The only mentions left are in historical plan and brainstorm docs.
  • Hashed the INLINE_SCRIPTS bodies separately with Python's hashlib, and each matches a source in the branch's CSP. I also fetched the live https://airglow.run/ (cf-cache-status EXPIRED/HIT). The three executable inline scripts it serves today hash to sources in the branch's CSP, so the production bodies will be allowed as they are.
  • The PR suggests an owner check for Cloudflare-injected inline scripts. I fetched /, /automations, /sync, /pricing and a 404 from the live site with a desktop-Chrome User-Agent. None carried any Cloudflare-injected script, inline or otherwise. That leaves nothing that relied on borrowing the nonce, so nothing is left to ask.
  • The tests now mount securityHeaders() on the landing harness. They hash every executable inline script on the rendered / and require each hash in the CSP, with no nonce in the HTML. scriptHashSource reproduces the CSP2 spec's worked example.
  • The issue names no out-of-scope items and has no followup.md. Nothing in the UI changes.

loop:verdict run=review-20261103-130643-93 sha=c8467627da7b8c0507e5d39594210a7cc755d488 verdict=merge

Merged main into the branch: 496573c497e457a1a86e3e6dfbf5749e7ef542bf.

loop:carry from=c8467627da7b8c0507e5d39594210a7cc755d488 sha=496573c497e457a1a86e3e6dfbf5749e7ef542bf

Merged onto main as 492da28831c43666828974b8f9a424944aba32c3.

loop:merged sha=492da28831c43666828974b8f9a424944aba32c3 head=496573c497e457a1a86e3e6dfbf5749e7ef542bf

Sign up or Sign in to add to the discussion.