feat(csp): de-brittle captcha style CSP via style-src-elem nonce #73

Sammanfogat
supernaut sammanfogade 1 incheckning från feat/64-csp in i main 2026-07-20 18:49:27 +00:00
Ägare

What

De-brittle the Cap captcha's CSP handling and adopt Astro 7.1's finer-grained style directives.

Today src/lib/cap-widget-csp.ts string-scraped the minified Cap widget source to compute a sha256 hash of its shadow-root inline <style>. That is brittle and fails closed: any upstream restyle changes the bytes, the hash no longer matches, and the captcha silently loses its styling.

This replaces the hash with a per-request nonce:

  • A fresh nonce is generated in signup-form.astro and allowed on style-src-elem via the Astro 7.1 runtime API (Astro.csp.insertStyleResource({ kind: "element", resource: "'nonce-…'" })), scoped to the sign-up page only.
  • The nonce is handed to the widget through its own window.CAP_CSS_NONCE hook (set in the page script before import("cap-widget")), so the widget renders <style nonce=…> and it is allowed.
  • src/lib/cap-widget-csp.ts is deleted and its call site removed.

Why a nonce, not 'unsafe-inline'

The issue suggested 'unsafe-inline' on the new style directive. In practice that does not work here, for two reasons verified against the running build:

  1. Astro rejects style-src-elem/-attr as raw strings in security.csp.directives — they must go through styleDirective/the runtime API with a kind.
  2. Astro always emits some inline <style> (the font-face block, server-island styles) and hashes them. When you scope anything to kind: "element", Astro moves those generated hashes onto style-src-elem — and per the CSP spec a hash in a directive neutralises 'unsafe-inline' in that same directive. So the captcha's <style> would still be blocked.

A nonce coexists with hashes (only 'unsafe-inline' is neutralised, never a nonce), so style-src-elem 'self' 'nonce-…' 'sha256-…(Astro's)…' allows: Astro's own inline styles (by hash), same-origin stylesheets (by 'self'), and the captcha's <style> (by nonce). This is more precise than 'unsafe-inline' (only that one <style>, not all inline styles), restyle-proof, and leaves script-src untouched.

Nonce exposure in the DOM (data-cap-css-nonce) is safe: it is per-request random, so static HTML injection can't know the current value, and anything able to read it from the DOM already executes script (script-src already bypassed).

Also in this PR

  • object-src 'none' added to security.csp.directives — this is #65 item L1. Kept here so all astro.config.mjs CSP edits live in one PR and don't conflict with the #65 branch.
  • deferRender: true on both glob() content loaders (guides, faq) in src/content.config.ts — the small build-memory win noted in the issue. Safe: these are SSR (output: "server") so they render on request anyway.

Verification

CSP is off in dev, enforced in build/preview, so verified against the built standalone server (node ./dist/server/entry.mjs):

  • pnpm check -> 0 errors. pnpm lint -> pass. pnpm build -> success, no CSP warnings. pnpm format:check -> clean.
  • /signup response CSP header contains style-src-elem 'self' 'nonce-<hex>' 'sha256-…' and object-src 'none'; script-src unchanged ('self' 'wasm-unsafe-eval' + Astro hashes).
  • Within a single response, the style-src-elem nonce matches the data-cap-css-nonce attribute the widget script reads (verified equal). CSP is delivered as a single HTTP header (node adapter staticHeaders); there is no duplicate <meta> CSP.
  • Non-captcha pages (e.g. /) get object-src 'none' but no style-src-elem/nonce — the nonce is page-scoped. /en/signup also carries the nonce.
  • The sign-up page opts out of the memory cache (Astro.cache.set(false); cache-control: private, no-store), so the per-request nonce is never cached/reused.

Closes #64

## What De-brittle the Cap captcha's CSP handling and adopt Astro 7.1's finer-grained style directives. Today `src/lib/cap-widget-csp.ts` string-scraped the **minified** Cap widget source to compute a `sha256` hash of its shadow-root inline `<style>`. That is brittle and fails **closed**: any upstream restyle changes the bytes, the hash no longer matches, and the captcha silently loses its styling. This replaces the hash with a **per-request nonce**: - A fresh nonce is generated in `signup-form.astro` and allowed on `style-src-elem` via the Astro 7.1 runtime API (`Astro.csp.insertStyleResource({ kind: "element", resource: "'nonce-…'" })`), scoped to the sign-up page only. - The nonce is handed to the widget through its own `window.CAP_CSS_NONCE` hook (set in the page script before `import("cap-widget")`), so the widget renders `<style nonce=…>` and it is allowed. - `src/lib/cap-widget-csp.ts` is **deleted** and its call site removed. ### Why a nonce, not `'unsafe-inline'` The issue suggested `'unsafe-inline'` on the new style directive. In practice that does **not** work here, for two reasons verified against the running build: 1. Astro rejects `style-src-elem`/`-attr` as raw strings in `security.csp.directives` — they must go through `styleDirective`/the runtime API with a `kind`. 2. Astro always emits some inline `<style>` (the font-face block, server-island styles) and hashes them. When you scope anything to `kind: "element"`, Astro **moves those generated hashes onto `style-src-elem`** — and per the CSP spec a hash in a directive **neutralises `'unsafe-inline'`** in that same directive. So the captcha's `<style>` would still be blocked. A **nonce coexists with hashes** (only `'unsafe-inline'` is neutralised, never a nonce), so `style-src-elem 'self' 'nonce-…' 'sha256-…(Astro's)…'` allows: Astro's own inline styles (by hash), same-origin stylesheets (by `'self'`), and the captcha's `<style>` (by nonce). This is **more precise** than `'unsafe-inline'` (only that one `<style>`, not all inline styles), restyle-proof, and leaves `script-src` untouched. Nonce exposure in the DOM (`data-cap-css-nonce`) is safe: it is per-request random, so static HTML injection can't know the current value, and anything able to read it from the DOM already executes script (script-src already bypassed). ## Also in this PR - **`object-src 'none'`** added to `security.csp.directives` — this is #65 item **L1**. Kept here so all `astro.config.mjs` CSP edits live in one PR and don't conflict with the #65 branch. - **`deferRender: true`** on both `glob()` content loaders (`guides`, `faq`) in `src/content.config.ts` — the small build-memory win noted in the issue. Safe: these are SSR (`output: "server"`) so they render on request anyway. ## Verification CSP is off in dev, enforced in `build`/`preview`, so verified against the built standalone server (`node ./dist/server/entry.mjs`): - `pnpm check` -> 0 errors. `pnpm lint` -> pass. `pnpm build` -> success, no CSP warnings. `pnpm format:check` -> clean. - `/signup` response CSP header contains `style-src-elem 'self' 'nonce-<hex>' 'sha256-…'` and `object-src 'none'`; `script-src` unchanged (`'self' 'wasm-unsafe-eval'` + Astro hashes). - Within a single response, the `style-src-elem` nonce **matches** the `data-cap-css-nonce` attribute the widget script reads (verified equal). CSP is delivered as a single HTTP header (node adapter `staticHeaders`); there is no duplicate `<meta>` CSP. - Non-captcha pages (e.g. `/`) get `object-src 'none'` but **no** `style-src-elem`/nonce — the nonce is page-scoped. `/en/signup` also carries the nonce. - The sign-up page opts out of the memory cache (`Astro.cache.set(false)`; `cache-control: private, no-store`), so the per-request nonce is never cached/reused. Closes #64
supernaut lade till 1 incheckning 2026-07-20 15:13:15 +00:00
feat(csp): de-brittle captcha style CSP via style-src-elem nonce
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m1s
46c003a8fc
Replace the fragile minified-source hash extraction (cap-widget-csp.ts)
with a per-request nonce handed to the Cap widget via its CAP_CSS_NONCE
hook and allowed on Astro 7.1's style-src-elem directive. A nonce is
restyle-proof (the old hash failed closed on any upstream restyle) and,
unlike 'unsafe-inline', coexists with Astro's own generated style hashes
without being neutralised. script-src is untouched and only the widget's
one shadow-root <style> is allowed, not all inline styles.

Also add object-src 'none' (#65 L1, kept here to avoid conflicting with
the #65 branch) and deferRender to the glob content loaders for a small
build-memory win.

Closes #64
supernaut sammanfogade incheckning 6f426ee937 till main 2026-07-20 18:49:27 +00:00
supernaut tog bort grenen feat/64-csp 2026-07-20 18:49:28 +00:00
supernaut lade till detta till projektet Bitborg Web 2026-07-21 06:49:01 +00:00
supernaut tog bort detta från projektet Bitborg Web 2026-07-30 22:40:41 +00:00
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-web!73
Ingen beskrivning angiven.