From 99ac64d6a2c751903af364b0a82e5857c6d15a9c Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Sat, 11 Jul 2026 12:08:27 +0200 Subject: [PATCH 1/6] docs: add strict CSP design spec --- .../specs/2026-07-11-strict-csp-design.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-strict-csp-design.md diff --git a/docs/superpowers/specs/2026-07-11-strict-csp-design.md b/docs/superpowers/specs/2026-07-11-strict-csp-design.md new file mode 100644 index 0000000..533fe60 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-strict-csp-design.md @@ -0,0 +1,85 @@ +# Strict Content-Security-Policy - design + +Date: 2026-07-11. Closes the "Strict CSP with nonces" item in +[production-readiness.md](../../production-readiness.md) (tracks #59). + +## Goal + +Ship an enforcing (not Report-Only) strict CSP with per-request nonces, +covering every HTML page including `/login`, without breaking auth +redirects, hydration, charts, drag-and-drop, or client-side PDF export. + +## Decisions + +- Pattern: per-request nonce generated in the middleware (official Next.js + App Router approach). The nonce travels to Next via a request header, so + the framework applies it to its own inline scripts; the response carries + the enforcing `Content-Security-Policy` header. +- Enforcing from day one, validated locally (prod build click-through) and + continuously by the step-4 smoke test. No Report-Only phase: the V1 + timeline has no production observation window. +- `style-src 'self' 'unsafe-inline'`: Tailwind and chart/DnD libraries set + inline styles. Accepted trade-off; the script surface is the one that + matters. +- Baseline headers stay in `next.config.ts` untouched. The CSP lives only + in the middleware, the only place a per-request nonce can exist. +- API routes stay out of the matcher (JSON responses, no CSP needed). + +## Directives + +Production: + +``` +default-src 'self'; script-src 'self' 'nonce-' 'strict-dynamic'; +style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; +font-src 'self'; connect-src 'self'; worker-src 'self' blob:; +object-src 'none'; base-uri 'self'; form-action 'self'; +frame-ancestors 'none' +``` + +Development additions: `'unsafe-eval'` in `script-src` (React Refresh) and +`ws:` in `connect-src` (HMR websocket). `worker-src blob:` covers +`@react-pdf/renderer` client-side export. + +## Files + +| File | Change | +| --- | --- | +| `src/lib/csp.ts` | New. Pure `buildCsp(nonce, dev)` returning the directive string. Separated from the middleware so vitest can cover it. | +| `src/lib/csp.test.ts` | New. Nonce presence, dev/prod differences, key directives. | +| `src/middleware.ts` | Generate nonce (`crypto.randomUUID()` base64-encoded via `btoa`), set it and the CSP on the request headers (Next reads the nonce from there), set the enforcing CSP on the response, keep the `auth()` wrapper. Widen the matcher. | +| `src/auth.config.ts` | `authorized` callback allows `/login` without a session (the widened matcher now covers it; without this, redirect loop). | +| `docs/production-readiness.md` | "Security headers review" -> Done; drop "Strict CSP with nonces" from the remaining list; update the headers section. | + +## Matcher + +``` +/((?!api|_next/static|_next/image|favicon.ico|fonts|.*\..*).*) +``` + +Includes `/login` (CSP applies there too); excludes API routes, static +assets, images, fonts, and any path with a file extension. + +## Auth interplay + +The `auth()` wrapper still runs first for redirects. Unauthorized requests +to protected pages get the usual redirect response (no body, CSP +irrelevant). `/login` returns `true` from `authorized` so the page renders; +previously the matcher skipped it entirely, so allowing it preserves +existing behavior. + +## Error handling + +None beyond framework guarantees. `buildCsp` is a pure string builder; +middleware failures surface as standard Next errors. + +## Verification + +- Unit: vitest on `buildCsp` (nonce embedded, `unsafe-eval`/`ws:` only when + dev, `strict-dynamic` present, `frame-ancestors 'none'`). +- Real: `npm run build && npm start`, click through login, dashboard, + alerts, reports (PDF export), settings, employees. Browser console must + show zero CSP violations; response header present with a fresh nonce per + request. +- Continuous: the step-4 Playwright smoke test runs against the enforcing + CSP. From dd1c3caaa35e887fe2b1425cc162996949836e3d Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Sat, 11 Jul 2026 12:09:49 +0200 Subject: [PATCH 2/6] docs: add strict CSP implementation plan --- .../plans/2026-07-11-strict-csp.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-strict-csp.md diff --git a/docs/superpowers/plans/2026-07-11-strict-csp.md b/docs/superpowers/plans/2026-07-11-strict-csp.md new file mode 100644 index 0000000..059303c --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-strict-csp.md @@ -0,0 +1,255 @@ +# Strict CSP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enforcing strict CSP with per-request nonces on every HTML page, including `/login`, without breaking auth, hydration, or client-side PDF export. + +**Architecture:** Pure `buildCsp(nonce, dev)` in `src/lib/csp.ts` (vitest-covered); `src/middleware.ts` generates the nonce, forwards it via request headers (Next applies it to its inline scripts), sets the response CSP, and keeps the `auth()` wrapper; matcher widened to cover `/login`, with `authorized` updated to allow it. + +**Tech Stack:** Next.js 15 App Router middleware (edge runtime), next-auth v5 wrapper, vitest. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-11-strict-csp-design.md`. Branch: `feat/strict-csp`. +- ASCII only. No code comments unless the WHY is non-obvious. +- Test imports: `import { describe, it, expect } from "vitest"` (repo style). +- `next.config.ts` stays untouched. +- Gates: `npm run lint -- --max-warnings 0`, `npx tsc --noEmit`, `npm test`. + +--- + +### Task 1: buildCsp pure function (TDD) + +**Files:** +- Create: `src/lib/csp.ts` +- Test: `src/lib/csp.test.ts` + +**Interfaces:** +- Produces: `buildCsp(nonce: string, dev: boolean): string`, consumed by Task 2. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest" +import { buildCsp } from "./csp" + +describe("buildCsp", () => { + it("embeds the nonce in script-src with strict-dynamic", () => { + const csp = buildCsp("abc123", false) + expect(csp).toContain("script-src 'self' 'nonce-abc123' 'strict-dynamic'") + }) + + it("omits dev-only sources in production", () => { + const csp = buildCsp("abc123", false) + expect(csp).not.toContain("unsafe-eval") + expect(csp).not.toContain("ws:") + }) + + it("adds unsafe-eval and ws: in development", () => { + const csp = buildCsp("abc123", true) + expect(csp).toContain("'unsafe-eval'") + expect(csp).toContain("connect-src 'self' ws:") + }) + + it("locks down framing and object sources", () => { + const csp = buildCsp("abc123", false) + expect(csp).toContain("frame-ancestors 'none'") + expect(csp).toContain("object-src 'none'") + expect(csp).toContain("base-uri 'self'") + expect(csp).toContain("form-action 'self'") + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/csp.test.ts` +Expected: FAIL, cannot resolve `./csp`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +export function buildCsp(nonce: string, dev: boolean): string { + const script = `'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}` + const connect = `'self'${dev ? " ws:" : ""}` + return [ + "default-src 'self'", + `script-src ${script}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' blob: data:", + "font-src 'self'", + `connect-src ${connect}`, + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; ") +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/csp.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/csp.ts src/lib/csp.test.ts +git commit -m "feat: add buildCsp directive builder" +``` + +### Task 2: Middleware nonce wiring and login allowance + +**Files:** +- Modify: `src/middleware.ts` (whole file, currently 8 lines) +- Modify: `src/auth.config.ts:7-9` (`authorized` callback) + +**Interfaces:** +- Consumes: `buildCsp(nonce: string, dev: boolean): string` from Task 1. +- Produces: enforcing `Content-Security-Policy` response header on all matched pages; `x-nonce` request header available to server components if ever needed. + +- [ ] **Step 1: Replace `src/middleware.ts`** + +```ts +import NextAuth from "next-auth" +import { NextResponse } from "next/server" +import { authConfig } from "@/auth.config" +import { buildCsp } from "@/lib/csp" + +const { auth } = NextAuth(authConfig) + +export default auth((req) => { + const nonce = btoa(crypto.randomUUID()) + const csp = buildCsp(nonce, process.env.NODE_ENV === "development") + + const requestHeaders = new Headers(req.headers) + requestHeaders.set("x-nonce", nonce) + requestHeaders.set("content-security-policy", csp) + + const res = NextResponse.next({ request: { headers: requestHeaders } }) + res.headers.set("Content-Security-Policy", csp) + return res +}) + +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|fonts|.*\\..*).*)"], +} +``` + +The CSP goes on the request headers too: that is how Next.js detects the +nonce and stamps it on its own inline scripts. + +- [ ] **Step 2: Allow `/login` in `src/auth.config.ts`** + +Replace: + +```ts + authorized({ auth }) { + return !!auth?.user + }, +``` + +with: + +```ts + authorized({ auth, request }) { + if (request.nextUrl.pathname.startsWith("/login")) return true + return !!auth?.user + }, +``` + +Without this, the widened matcher sends logged-out visitors of `/login` +into a redirect loop. + +- [ ] **Step 3: Gates** + +Run: `npm run lint -- --max-warnings 0 && npx tsc --noEmit && npm test` +Expected: clean, 152 tests (148 + 4 new). + +- [ ] **Step 4: Header check against the dev stack** + +With the compose stack up (volume-mounted, HMR picks the change): + +Run: `curl -sI http://localhost:3000/login | grep -i content-security-policy` +Expected: one `content-security-policy` line containing `'nonce-` and +`'strict-dynamic'` plus dev-only `'unsafe-eval'` and `ws:`. + +Run twice; the nonce must differ between requests. + +Run: `curl -sI http://localhost:3000/api/health | grep -ci content-security-policy` +Expected: `0` (API routes excluded). + +Run: `curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:3000/` +Expected: `307 http://localhost:3000/login...` (auth redirect intact). + +- [ ] **Step 5: Commit** + +```bash +git add src/middleware.ts src/auth.config.ts +git commit -m "feat: enforce strict CSP with per-request nonce" +``` + +### Task 3: Documentation + +**Files:** +- Modify: `docs/production-readiness.md` (headers row, headers section, remaining list) + +- [ ] **Step 1: Update the checklist row** + +Replace: + +```markdown +| Security headers review | Done (baseline) | See below; strict CSP still pending. | +``` + +with: + +```markdown +| Security headers review | Done | Baseline headers plus enforcing strict CSP with per-request nonces; see below. | +``` + +- [ ] **Step 2: Update the headers section** + +Replace the paragraph: + +```markdown +Pending: a strict `Content-Security-Policy`. The App Router needs per-request +nonces for inline scripts/styles, so it is tracked as a follow-up rather than +shipped loose. +``` + +with: + +```markdown +A strict `Content-Security-Policy` is enforced by `src/middleware.ts`: +per-request nonce with `strict-dynamic` for scripts, `'self'` defaults, +`frame-ancestors 'none'`. Styles allow `'unsafe-inline'` (Tailwind and chart +libraries); the directive string is built by `src/lib/csp.ts`. +``` + +- [ ] **Step 3: Trim the remaining list** + +Delete the line `- Strict CSP with nonces.` from "Remaining before removing +the WIP banner". + +- [ ] **Step 4: Commit** + +```bash +git add docs/production-readiness.md +git commit -m "docs: mark strict CSP as done" +``` + +### Task 4: Manual browser verification + +**Files:** none. + +- [ ] **Step 1: User click-through** + +Ask the user to browse the running app (login, dashboard, alerts, reports +including PDF export, settings, employees) with the browser console open. +Expected: zero CSP violation messages. If a violation appears (likely +`worker-src` or `img-src` from a client library), capture the exact console +line and adjust the corresponding directive in `src/lib/csp.ts` plus its +test, then re-run gates and amend with a fix commit. From 57fea5b25c120c7fecaf904986921b858c1ccae2 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Sat, 11 Jul 2026 12:10:55 +0200 Subject: [PATCH 3/6] feat: add buildCsp directive builder --- src/lib/csp.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/csp.ts | 17 +++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/lib/csp.test.ts create mode 100644 src/lib/csp.ts diff --git a/src/lib/csp.test.ts b/src/lib/csp.test.ts new file mode 100644 index 0000000..89b4e58 --- /dev/null +++ b/src/lib/csp.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest" +import { buildCsp } from "./csp" + +describe("buildCsp", () => { + it("embeds the nonce in script-src with strict-dynamic", () => { + const csp = buildCsp("abc123", false) + expect(csp).toContain("script-src 'self' 'nonce-abc123' 'strict-dynamic'") + }) + + it("omits dev-only sources in production", () => { + const csp = buildCsp("abc123", false) + expect(csp).not.toContain("unsafe-eval") + expect(csp).not.toContain("ws:") + }) + + it("adds unsafe-eval and ws: in development", () => { + const csp = buildCsp("abc123", true) + expect(csp).toContain("'unsafe-eval'") + expect(csp).toContain("connect-src 'self' ws:") + }) + + it("locks down framing and object sources", () => { + const csp = buildCsp("abc123", false) + expect(csp).toContain("frame-ancestors 'none'") + expect(csp).toContain("object-src 'none'") + expect(csp).toContain("base-uri 'self'") + expect(csp).toContain("form-action 'self'") + }) +}) diff --git a/src/lib/csp.ts b/src/lib/csp.ts new file mode 100644 index 0000000..7dd074c --- /dev/null +++ b/src/lib/csp.ts @@ -0,0 +1,17 @@ +export function buildCsp(nonce: string, dev: boolean): string { + const script = `'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}` + const connect = `'self'${dev ? " ws:" : ""}` + return [ + "default-src 'self'", + `script-src ${script}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' blob: data:", + "font-src 'self'", + `connect-src ${connect}`, + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; ") +} From e67ed114c0231d2ce007fc18923be53297f4c4af Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Sat, 11 Jul 2026 12:12:36 +0200 Subject: [PATCH 4/6] feat: enforce strict CSP with per-request nonce --- src/auth.config.ts | 3 ++- src/middleware.ts | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/auth.config.ts b/src/auth.config.ts index e13c445..027cce0 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -4,7 +4,8 @@ export const authConfig: NextAuthConfig = { pages: { signIn: "/login" }, session: { strategy: "jwt" }, callbacks: { - authorized({ auth }) { + authorized({ auth, request }) { + if (request.nextUrl.pathname.startsWith("/login")) return true return !!auth?.user }, jwt({ token, user }) { diff --git a/src/middleware.ts b/src/middleware.ts index 7b3504a..a3bf73a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,25 @@ import NextAuth from "next-auth" +import { NextResponse } from "next/server" import { authConfig } from "@/auth.config" +import { buildCsp } from "@/lib/csp" -export const { auth: middleware } = NextAuth(authConfig) +const { auth } = NextAuth(authConfig) + +export default auth((req) => { + const nonce = btoa(crypto.randomUUID()) + const csp = buildCsp(nonce, process.env.NODE_ENV === "development") + + // Next.js reads the nonce from the request CSP header and stamps it on + // its own inline scripts; without this the response header would block them. + const requestHeaders = new Headers(req.headers) + requestHeaders.set("x-nonce", nonce) + requestHeaders.set("content-security-policy", csp) + + const res = NextResponse.next({ request: { headers: requestHeaders } }) + res.headers.set("Content-Security-Policy", csp) + return res +}) export const config = { - matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|fonts|login|.*\\..*).*)",], + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|fonts|.*\\..*).*)"], } From ce3e7751c32c402e99f29f5ba92dc54598407335 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Sat, 11 Jul 2026 12:13:00 +0200 Subject: [PATCH 5/6] docs: mark strict CSP as done --- docs/production-readiness.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/production-readiness.md b/docs/production-readiness.md index 67ae782..7aa83e9 100644 --- a/docs/production-readiness.md +++ b/docs/production-readiness.md @@ -9,7 +9,7 @@ Tracks #59. The README WIP banner stays until every item is **Done**. | Secrets management (no plaintext) | Partial | Connection/provider configs encrypted at rest (AES-256-GCM, see [encryption.md](encryption.md)). Env secrets via the environment, never committed; CI secret scan enforces this. | | Application healthcheck | Done | `GET /api/health` pings the DB; 200 `{status:"ok"}` / 503 on failure. | | Logging policy (zero PII / secrets) | Done (policy) | See below. | -| Security headers review | Done (baseline) | See below; strict CSP still pending. | +| Security headers review | Done | Baseline headers plus enforcing strict CSP with per-request nonces; see below. | ## Logging policy (zero PII / secrets) @@ -27,12 +27,12 @@ Baseline headers are applied to every response in `next.config.ts`: `Permissions-Policy` (camera/mic/geolocation disabled), and `Strict-Transport-Security` (2y, includeSubDomains, preload). -Pending: a strict `Content-Security-Policy`. The App Router needs per-request -nonces for inline scripts/styles, so it is tracked as a follow-up rather than -shipped loose. +A strict `Content-Security-Policy` is enforced by `src/middleware.ts`: +per-request nonce with `strict-dynamic` for scripts, `'self'` defaults, +`frame-ancestors 'none'`. Styles allow `'unsafe-inline'` (Tailwind and chart +libraries); the directive string is built by `src/lib/csp.ts`. ## Remaining before removing the WIP banner - DB backup strategy (tied to the deferred Docker/prod hosting decision). - Squashed migration baseline (optional). -- Strict CSP with nonces. From 670482cb0eed443ff0af39272c1ee7c64ddfe711 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Sat, 11 Jul 2026 12:20:42 +0200 Subject: [PATCH 6/6] fix: exact-match login path in auth allowlist --- src/auth.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth.config.ts b/src/auth.config.ts index 027cce0..c66b8dd 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -5,7 +5,8 @@ export const authConfig: NextAuthConfig = { session: { strategy: "jwt" }, callbacks: { authorized({ auth, request }) { - if (request.nextUrl.pathname.startsWith("/login")) return true + const path = request.nextUrl.pathname + if (path === "/login" || path.startsWith("/login/")) return true return !!auth?.user }, jwt({ token, user }) {