Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/production-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -27,11 +27,11 @@ 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

- Squashed migration baseline (optional).
- Strict CSP with nonces.
255 changes: 255 additions & 0 deletions docs/superpowers/plans/2026-07-11-strict-csp.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions docs/superpowers/specs/2026-07-11-strict-csp-design.md
Original file line number Diff line number Diff line change
@@ -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-<n>' '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.
4 changes: 3 additions & 1 deletion src/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export const authConfig: NextAuthConfig = {
pages: { signIn: "/login" },
session: { strategy: "jwt" },
callbacks: {
authorized({ auth }) {
authorized({ auth, request }) {
const path = request.nextUrl.pathname
if (path === "/login" || path.startsWith("/login/")) return true
return !!auth?.user
},
jwt({ token, user }) {
Expand Down
Loading