From e0ac65b8efe2126968fd01f3dac0fbee4b89ef34 Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 6 Jun 2026 02:57:46 -0400 Subject: [PATCH] hardening: security headers/CSP, rate limiting, audit logging, service-role audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production security hardening pass (Phase 7), layered on top of the existing RLS + session-gated auth without weakening any auth/money/RLS correctness. Zero new npm dependency; build + Vitest stay green with zero env vars. - Security headers + CSP (src/lib/security/headers.ts, wired via next.config.ts headers()): strict static CSP (default-src 'self', object-src 'none', frame-ancestors 'none', enumerated connect/frame/script-src for Supabase + Stripe + Base RPC, upgrade-insecure-requests) + HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP. No-nonce trade-off documented (middleware matches protected surfaces only). - Rate limiting (src/lib/security/{rate-limit,http}.ts): zero-dep in-memory fixed-window limiter + route guard returning 429 + Retry-After. Applied to PIN, customer magic-link (per IP+email), order create, payment, refund. No-op under tests / RATE_LIMIT_DISABLED so CI + dev never throttle. - Audit logging (src/lib/security/audit.ts + expanded AuditAction): tenant- scoped, fail-open coverage for staff/admin sign-in, PIN switch, refund/void, menu 86, Connect change, subscription change, tenant go-live. - Service-role audit (PRODUCTION_READINESS §11d): inventory confirms the service-role key is used only by the driver, every tenant query carries an explicit tenant_id/location_id filter. Fixed an authz gap: the refund endpoint had NO auth (any caller could refund any payment) — now requires tenant membership; Connect start now requires owner|manager. - Input validation (src/lib/security/validate.ts): 256 KB body cap (413), non-negative integer money guards, bounded id/email guards on the money + auth routes. - Tests: rate limiter, HTTP guard/no-op, validators, CSP/headers, audit (incl. fail-open). 150 tests pass; typecheck + lint + build green, zero env. - Threat model: plans/arrabbiata-71129-phase-7-security-hardening.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/PRODUCTION_READINESS.md | 145 ++++++++++++++++++ next.config.ts | 12 ++ ...bbiata-71129-phase-7-security-hardening.md | 63 ++++++++ src/app/api/admin/overrides/route.ts | 31 +++- src/app/api/billing/route.ts | 37 ++++- src/app/api/connect/route.ts | 26 +++- src/app/api/orders/route.ts | 16 +- src/app/api/payments/refund/route.ts | 52 ++++++- src/app/api/payments/route.ts | 28 +++- src/app/api/shop/auth/magic-link/route.ts | 25 ++- src/app/api/signup/route.ts | 23 ++- src/app/api/terminal/pin/route.ts | 25 ++- src/app/auth/callback/route.ts | 38 +++++ src/lib/db/saas-types.ts | 26 +++- src/lib/security/audit.test.ts | 49 ++++++ src/lib/security/audit.ts | 70 +++++++++ src/lib/security/headers.test.ts | 51 ++++++ src/lib/security/headers.ts | 129 ++++++++++++++++ src/lib/security/http.test.ts | 42 +++++ src/lib/security/http.ts | 96 ++++++++++++ src/lib/security/index.ts | 11 ++ src/lib/security/rate-limit.test.ts | 78 ++++++++++ src/lib/security/rate-limit.ts | 138 +++++++++++++++++ src/lib/security/validate.test.ts | 86 +++++++++++ src/lib/security/validate.ts | 85 ++++++++++ 25 files changed, 1337 insertions(+), 45 deletions(-) create mode 100644 plans/arrabbiata-71129-phase-7-security-hardening.md create mode 100644 src/lib/security/audit.test.ts create mode 100644 src/lib/security/audit.ts create mode 100644 src/lib/security/headers.test.ts create mode 100644 src/lib/security/headers.ts create mode 100644 src/lib/security/http.test.ts create mode 100644 src/lib/security/http.ts create mode 100644 src/lib/security/index.ts create mode 100644 src/lib/security/rate-limit.test.ts create mode 100644 src/lib/security/rate-limit.ts create mode 100644 src/lib/security/validate.test.ts create mode 100644 src/lib/security/validate.ts 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 `