Skip to content

feat(security): HTTP security headers and a nonce-based CSP (F9) - #86

Merged
chriskehayias merged 1 commit into
mainfrom
feat/security-headers-f9
Sep 12, 2026
Merged

chriskehayias merged 1 commit into
mainfrom
feat/security-headers-f9

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

Closes F9 of the 2026-09-12 auth review — "No HTTP security headers". next.config.ts was empty: no CSP, no anti-framing header, no HSTS, no Referrer-Policy.

What lands

next.config.ts src/proxy.ts
What X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, HSTS (prod only) Content-Security-Policy
Why there request-independent carries a per-request nonce, which a build-time value cannot
Reaches every response, /api and static assets included the paths the proxy matcher covers

Both read from the new src/lib/security-headers.ts. Anti-framing is expressed twice on purpose — X-Frame-Options reaches the routes the proxy skips, frame-ancestors covers 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=true flips 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 the form-action reason below.

Three deliberate loosenings

Documented in .claude/references/security-headers.md 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.
  • 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.

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" moduleexport const dynamic sat inert in the old page and the build output still read ○ /signin. So the page body 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.

Verification

Against a real next start, not only unit tests:

content-security-policy-report-only: default-src 'self'; script-src 'self'
  'nonce-hoT5ClT7r18BIpStZfCD8A==' 'strict-dynamic'; style-src 'self' 'nonce-…';
  style-src-attr 'unsafe-inline'; img-src 'self' data: blob: https://files.example.com;
  font-src 'self'; connect-src 'self'; object-src 'none'; frame-src 'none';
  base-uri 'self'; form-action 'self' https://mp.example.com;
  frame-ancestors 'none'; upgrade-insecure-requests

14 script tags carried the nonce, 0 without.

Known gap

/_not-found is 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

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

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chriskehayias
chriskehayias merged commit 257a3be into main Sep 12, 2026
3 checks passed
@chriskehayias
chriskehayias deleted the feat/security-headers-f9 branch September 12, 2026 23:22
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