diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 547687b..a767a05 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -344,3 +344,148 @@ secrets**). Everything is optional; the app builds + the suite passes with none. - [ ] Hardware field test (reader, printer, drawer, tablet) — separate from this code phase. - [ ] Re-run the full Vitest suite + production build with the live env; both green. + +--- + +## 11. Security hardening (headers/CSP, rate limiting, audit, service-role) + +Defense-in-depth added in the hardening pass, layered on top of (never replacing) +RLS + the session-gated auth model. All of it is **zero-env safe** (build + Vitest +green with no env) and adds **no new npm dependency**. Code lives under +`src/lib/security/*`; the threat model is in +`plans/arrabbiata-71129-phase-7-security-hardening.md`. + +### 11a. Security headers + Content-Security-Policy + +Applied to **every** route via `next.config.ts` `headers()` (source +`src/lib/security/headers.ts`), so `/shop`, `/`, and static routes are covered too +(middleware only matches the protected surfaces). Static values — no env reads. + +| Header | Value (summary) | Defends | +|---|---|---| +| `Content-Security-Policy` | `default-src 'self'`; enumerated `script/style/img/font/connect/frame/worker/manifest-src`; `object-src 'none'`; `base-uri 'self'`; `form-action 'self'`; `frame-ancestors 'none'`; `upgrade-insecure-requests` | XSS, injection, clickjacking, downgrade | +| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | HTTPS downgrade/MITM (inert on localhost) | +| `X-Content-Type-Options` | `nosniff` | MIME-sniffing | +| `X-Frame-Options` | `DENY` | clickjacking (legacy companion to `frame-ancestors`) | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | referrer leakage | +| `Permissions-Policy` | `camera=()`, `microphone=()`, `geolocation=()`, `payment=(self)`, `usb=()`, … | feature abuse | +| `Cross-Origin-Opener-Policy` | `same-origin` | cross-origin tab interference | + +**CSP nonce/hashing approach — documented trade-off.** The policy is **static** +(no per-request nonce). Next.js App Router injects framework **inline** runtime +scripts on every page; a nonce-based `script-src` would require minting a nonce in +middleware and threading it through the document, but this app's middleware +intentionally matches the protected surfaces only (`/admin|/terminal|/kitchen| +/platform`) — `/shop`, `/`, and statically-rendered routes are not matched, so a +nonce cannot be applied uniformly without broadening the matcher (out of scope and +it would break static optimisation). We therefore allow `script-src 'self' +'unsafe-inline'` (+ Stripe origins). This is weaker than a nonce, but the app +ships **no author-controlled inline scripts** (no inline `