feat(security): HTTP security headers and a nonce-based CSP (F9) - #86
Merged
Merged
Conversation
The app shipped with an empty `next.config.ts`: no Content-Security-Policy, no anti-framing header, no HSTS, no Referrer-Policy. The session cookie is the only credential this app has and every page renders strings that came out of Ministry Platform, so a script injection anywhere was an immediate session-theft path. This is the defense-in-depth layer under F1/F2/F7. The headers are split across two files by what each needs to know about the request. `next.config.ts` carries the request-independent ones on `/(.*)`, so they also reach `/api` and the static-asset paths the proxy matcher skips: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and HSTS (production only, no `preload`). `src/proxy.ts` carries the CSP, because a nonce has to be minted per request; a value fixed at build time would be a constant an attacker can read off any page. Both read their values from the new `src/lib/security-headers.ts`. Anti-framing is expressed twice on purpose — X-Frame-Options in the config reaches the routes the proxy skips, frame-ancestors covers the rest. They are not both CSP headers: two Content-Security-Policy headers on one response are enforced as an intersection, which is miserable to debug. The CSP ships REPORT-ONLY. `CSP_ENFORCE=true` switches it, and only that exact string does, so a typo cannot take a deploy down. A nonce CSP is the one security header that can white-screen an app, and the browser walk that has to precede enforcement (sign-in, sign-out, contact photos, every Radix surface) has not happened yet. Three loosenings are deliberate and documented so nobody "tightens" them back into an outage: - `style-src-attr 'unsafe-inline'` — Radix and vaul position every popover, dialog, select and drawer with inline style ATTRIBUTES, which a nonce cannot cover. Without it every floating surface renders in the wrong place. - `form-action` includes the MP origin — sign-out is a form-driven server action ending in a redirect to MP's endsession endpoint, and browsers apply form-action to the whole redirect chain, not just its first hop. - `img-src` includes the MP file origin — contact photos are `next/image` with `unoptimized`, so the browser fetches them straight from MP. Nonces force dynamic rendering, which is what the /signin change is about. A page prerendered at build time has no request, so no nonce, so under enforcement its bootstrap script is blocked and it never hydrates — and /signin does nothing but run client-side effects, so an unhydrated one is a permanent spinner that never reaches Ministry Platform. Route segment config is IGNORED in a module marked "use client", so `export const dynamic` sat inert in the old page and the build output still read "○ /signin". The page body therefore moved to `src/components/sign-in/`, leaving the route file a server component that can actually opt out. /session-error needed only the export. All 22 existing /signin tests pass unchanged through the wrapper; new tests pin both the export and the absence of "use client", since either one silently reverts the fix. Verified against a real `next start`, not only in unit tests: every header lands, and all 14 script tags on /signin carry the nonce with none without. Known gap: /_not-found is still prerendered. It is Next's built-in 404, it renders its HTML and has no interactivity to lose, so it is accepted rather than papered over with a custom page nobody asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Closes F9 of the 2026-09-12 auth review — "No HTTP security headers".
next.config.tswas empty: no CSP, no anti-framing header, no HSTS, noReferrer-Policy.What lands
next.config.tssrc/proxy.tsX-Frame-Options: DENY,X-Content-Type-Options,Referrer-Policy,Permissions-Policy, HSTS (prod only)Content-Security-Policy/apiand static assets includedBoth read from the new
src/lib/security-headers.ts. Anti-framing is expressed twice on purpose —X-Frame-Optionsreaches the routes the proxy skips,frame-ancestorscovers the rest. Deliberately not two CSP headers: those are enforced as an intersection, which is miserable to debug.The CSP ships report-only
CSP_ENFORCE=trueflips it, and only that exact string does, so a typo can't take a deploy down. A nonce CSP is the one security header that can white-screen an app, and the browser walk that should precede enforcement hasn't happened yet — sign-out is the step I'd watch, for theform-actionreason below.Three deliberate loosenings
Documented in
.claude/references/security-headers.mdso nobody "tightens" them back into an outage:style-src-attr 'unsafe-inline'— Radix and vaul position every popover, dialog, select and drawer with inline style attributes, which a nonce cannot cover.form-actionincludes the MP origin — sign-out is a form-driven server action ending in a redirect to MP's endsession endpoint, and browsers applyform-actionto the whole redirect chain, not just its first hop.img-srcincludes the MP file origin — contact photos arenext/imagewithunoptimized, so the browser fetches them straight from MP.Why /signin moved
Nonces force dynamic rendering: a prerendered page has no request, so no nonce, so under enforcement its bootstrap script is blocked and it never hydrates. /signin does nothing but run client-side effects, so an unhydrated one is a permanent spinner that never reaches Ministry Platform.
The catch is that route segment config is ignored in a
"use client"module —export const dynamicsat inert in the old page and the build output still read○ /signin. So the page body moved tosrc/components/sign-in/, leaving the route file a server component that can actually opt out./session-errorneeded only the export.All 22 existing /signin tests pass unchanged through the wrapper. New tests pin both the export and the absence of
"use client", since either one silently reverts the fix.Verification
Against a real
next start, not only unit tests:14 script tags carried the nonce, 0 without.
src/proxy.tsstill at its required 100%no-consolerule),tsc --noEmit, and the production build all cleanmainafter fix(logging): remove PII/pastoral-note debug logging from src (F5) #84 (F5) and fix(contact-logs): make Made_By and Contact_ID server-authoritative (F4) #85 (F4); the F5 log removals are carried into the moved sign-in componentKnown gap
/_not-foundis still prerendered. It's Next's built-in 404 — it renders its HTML and has no interactivity to lose, so it's accepted rather than papered over with a custom page nobody asked for. Noted in the reference doc.Follow-up (not in this PR)
The browser walk, then
CSP_ENFORCE=true.🤖 Generated with Claude Code