feat(identity): Content-Security-Policy for server-rendered pages (P0.2, 0.0.8-rc.53) - #120
Merged
Conversation
….2, 0.0.8-rc.53) Every server-rendered page now carries a Content-Security-Policy, attached at the single HTML choke point (`htmlResponse` in oauth-shared.ts) so JSON/machine endpoints (token/JWKS/webhook) never get one. Forward-compatible with the Phase 1 same-origin SPA. Policy (strict ceremony default): default-src 'none'; script-src 'self' 'nonce-<per-response>'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none' - script-src is nonce-gated, NEVER 'unsafe-inline' — the protection that matters. The worker has exactly ONE inline script (the console's create-moment/clipboard/checklist JS, ui.ts consoleScript); it now carries the per-response nonce. No inline event handlers or javascript: URIs exist, so nothing else needs 'unsafe-inline'. - style-src keeps 'unsafe-inline' (PW4 [PLAN-DECISION]): the pages carry inline <style> blocks AND 100+ inline style="" attributes; a nonce covers <style> ELEMENTS but not style attributes, and style injection is far lower-risk than script injection. Migrating the attribute styles out is a later cleanup. - form-action 'self' fits every form — all post same-origin; billing's checkout form posts same-origin then the worker 302s to Stripe (form-action governs the form target, not the server redirect), verified in billing.ts. The nonce mechanism (centralized, injection-safe): a template stamps the marker NONCE_ATTR (= nonce="__CSP_NONCE__") on inline <script>; htmlResponse mints one 128-bit nonce per response, substitutes it into the body, and emits the matching 'nonce-…' in the header — header and body agree by construction. esc() escapes every " to ", so the marker (which needs literal double-quotes) can only come from a trusted template, never user data — the substitution can't be spoofed into nonce-ing attacker HTML. Per-route ready (PW4 two-tier connect-src): contentSecurityPolicy(nonce, opts) RETURNS the policy string (not a constant), so Phase 1's SPA route can widen connect-src to the vault REST + live-query WS origins without touching the ceremony default. Tests (test/csp.test.ts, +23 → identity 481): the policy helper's exact directives + the connect-src widen; htmlResponse nonce threading (header==body, marker never leaks, fresh per response, injection can't forge it, un-nonced scripts stay un-nonced); and a render-scan of every ceremony page (login, signup, authorize + error, console zero-vault + ≥1-vault incl. the create-moment script, security/TOTP, admin overview/users/vaults) asserting the CSP header is present and ZERO inline <script> lacks a nonce. Refs #116, plan-critique PW4. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
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.
P0.2 on the Parachute App campaign (#116). Adds a Content-Security-Policy to every server-rendered page on the identity worker — which today serves security-critical HTML (login, signup, console, consent, security, admin, billing) with no CSP at all. Required now, forward-compatible with Phase 1 serving a JS SPA on the same origin as the OAuth ceremonies + session cookie.
Grounded in the plan (
PHASES-INFRA-BREAKDOWN.mdP0.2) and the critique note PW4 (two-tier connect-src).The policy (strict ceremony default)
Where it's applied
Every HTML response funnels through
htmlResponse(oauth-shared.ts) — the single choke point (verified: authorize/login/consent/error, console, signup, security, magic, 2fa, admin, unsubscribe all go through it; the twonew Response(res.body,…)re-wraps are the export tarball and the authorize cookie re-wrap, which copies the header forward). JSON/machine endpoints (token/JWKS/webhook) go throughjsonResponseand correctly get no CSP.The nonce approach — which inline scripts got nonced
The worker has exactly one inline
<script>:consoleScript(ui.ts, the console's create-moment + clipboard + checklist-toggle JS). It now emits<script nonce="__CSP_NONCE__">;htmlResponsemints one 128-bit nonce per response, substitutes it into the body, and emits the matching'nonce-…'in the header — header and body agree by construction (one generation, substituted in both). No'unsafe-inline'for script.Injection-safe:
esc()escapes every"→", so the markernonce="__CSP_NONCE__"(which needs literal double-quotes) can only originate from a trusted template literal, never user data — the nonce grant can't be spoofed onto attacker HTML.No other executable inline surface: grepped for
on*=event-handler attributes andjavascript:URIs → none. The console script uses only programmaticaddEventListener. So the nonce-basedscript-srcbreaks nothing.Policy compromises (justified)
style-src 'unsafe-inline'(PW4 [PLAN-DECISION]): the pages carry inline<style>blocks (STYLE inpage(), ADMIN_STYLE) and 100+ inlinestyle="…"attributes. A nonce covers<style>elements but not style attributes, so'unsafe-inline'is required unless all attribute styles are refactored out (a later cleanup). Style injection ≪ script injection — accepted.form-action 'self': all 20 form actions are same-origin. Verified billing's checkout is a same-origin form → server 302 to Stripe (redirectResponse(session.url)), whichform-actiondoes not govern — Checkout is not broken.Per-route ready (PW4 two-tier connect-src)
contentSecurityPolicy(nonce, opts)returns the policy string (not a constant). Phase 1's SPA route can widenconnect-srcto'self' https://u.parachute.computer wss://u.parachute.computerin one call, leaving the ceremony default strict. This PR only governs the server-rendered pages.Verification (exact counts)
test/csp.test.ts) — policy-helper directives + connect-src widen;htmlResponsenonce threading (header==body nonce, marker never leaks, fresh per response, injection can't forge it, un-nonced scripts stay un-nonced); render-scan of every ceremony page asserting the CSP header + zero un-nonced inline<script>(the miss-catcher), with a positive control that the console genuinely carries a nonced inline script.No deploys/secrets/migrations. Version bump rc.52 → rc.53. CLAUDE.md security-posture section updated with the CSP.
Refs #116, plan-critique PW4.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB