Skip to content

feat(identity): Content-Security-Policy for server-rendered pages (P0.2, 0.0.8-rc.53) - #120

Merged
unforced merged 1 commit into
mainfrom
ag-p02-csp
Jul 10, 2026
Merged

feat(identity): Content-Security-Policy for server-rendered pages (P0.2, 0.0.8-rc.53)#120
unforced merged 1 commit into
mainfrom
ag-p02-csp

Conversation

@unforced

Copy link
Copy Markdown
Contributor

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.md P0.2) and the critique note PW4 (two-tier connect-src).

The 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'

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 two new 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 through jsonResponse and 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__">; 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 (one generation, substituted in both). No 'unsafe-inline' for script.

Injection-safe: esc() escapes every "&quot;, so the marker nonce="__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 and javascript: URIs → none. The console script uses only programmatic addEventListener. So the nonce-based script-src breaks nothing.

Policy compromises (justified)

  • style-src 'unsafe-inline' (PW4 [PLAN-DECISION]): the pages carry inline <style> blocks (STYLE in page(), ADMIN_STYLE) and 100+ inline style="…" 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)), which form-action does 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 widen connect-src to 'self' https://u.parachute.computer wss://u.parachute.computer in one call, leaving the ceremony default strict. This PR only governs the server-rendered pages.

Verification (exact counts)

  • identity vitest: 481 pass (was 458; +23 in test/csp.test.ts) — policy-helper directives + connect-src widen; htmlResponse nonce 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.
  • identity typecheck: clean. root typecheck: clean. root test: 125 pass / 0 fail (unchanged — identity-only).

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

….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 &quot;, 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
@unforced
unforced merged commit 62d0635 into main Jul 10, 2026
3 checks passed
@unforced
unforced deleted the ag-p02-csp branch July 10, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant