import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; /** * Per-request CSP nonce - drops `'unsafe-inline'` from script-src in * prod by giving every inline script a unique nonce that Next reads * from the `content-security-policy` REQUEST header and threads through * its RSC bootstrap + Server Actions. build-auditor H1. * * Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next HMR injects * runtime-evaluated scripts the nonce mechanism doesn't reach. * style-src stays at `'unsafe-inline'` because Tailwind/Radix runtime * style injection has no nonce story yet (revisit when Tailwind v5 * ships a nonce-able API). */ function buildCspWithNonce(nonce: string, isProd: boolean): string { const scriptSrc = isProd ? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'` : "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://unpkg.com https://unpkg.com"; const connectSrc = isProd ? "connect-src 'self' ws: wss: https:" : "connect-src 'self' ws: wss: https: http://unpkg.com https://unpkg.com"; return [ "default-src 'self'", scriptSrc, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self' data:", connectSrc, "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", "object-src 'none'", ].join('; '); } function generateNonce(): string { // crypto.randomUUID is collision-free across requests; base64-encode // to match the CSP spec shape (no `-`/`_`-only restrictions for // CSP3 nonce values, but base64 stays safe across HTTP header // serialization). return Buffer.from(crypto.randomUUID()).toString('base64'); } /** * Paths that do not require an authenticated session. * Checked with startsWith, so /auth/ covers /auth/callback etc. */ const PUBLIC_PATHS: string[] = [ '/login', '/reset-password', '/set-password', '/auth/', '/api/auth/', '/api/public/', '/api/health', '/api/webhooks/', // First-run / cold-start: the unauthenticated /setup and /login pages // call /api/v1/bootstrap/status to decide whether to render the setup // form. The route handlers self-protect via hasAnySuperAdmin(). '/setup', '/api/v1/bootstrap/', '/scan', // Tracked-link redirector. Outbound sales email embeds public // `/q/` links whose only audience is unauthenticated // external recipients. The route self-protects (validates the slug // regex before any DB hit and only 302s to an admin-stored target), // so it belongs on the anonymous allowlist. Without this, every // tracked link bounced recipients to /login (audit C4). '/q/', // §7.1: public sales-playbook docs (deal pulse, etc) so the "Full // guide" link inside the in-app popover is reachable without a // session - and shareable to external collaborators. '/docs/', // M-R01: portal allowlist narrowed from blanket `/portal/` to the // unauthenticated entry-point routes only. Other `/portal/*` paths // now flow through the middleware backstop below which redirects to // `/portal/login` when the portal_session cookie is missing. Closes // the silent-bypass class where a new portal route landed without // its own session check. '/portal/login', '/portal/activate', '/portal/reset-password', // Portal API endpoints handle their own session checks (better-auth). '/api/portal/', // Token-gated email-change endpoints. The confirm/cancel links land in // a fresh browser (the user may not be signed in on this device), so // they need to bypass the session 401 gate. The endpoints validate a // signed sha256-hashed token instead - that's the auth. '/api/v1/me/email/confirm/', '/api/v1/me/email/cancel/', ]; function isPublicPath(pathname: string): boolean { // Per-port PWA manifests sit under `//scan/manifest.webmanifest` // and need to be fetchable without a session - browsers fetch them eagerly // during install / first paint. The manifest only contains port name + // icon paths, no sensitive data, so making it public is safe. if (pathname.endsWith('/scan/manifest.webmanifest')) return true; return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix)); } function isApiRoute(pathname: string): boolean { return pathname.startsWith('/api/'); } const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); /** * SameSite=Lax cookies block top-level cross-site POSTs in modern browsers, * but defense-in-depth: every state-changing request to a session-authed * `/api/v1/**` endpoint must originate from the same origin as the app. * Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are * exempt because they're called by external systems with no session * cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle * their own origin/CSRF checks via better-auth. */ function isOriginCheckedPath(pathname: string): boolean { if (!pathname.startsWith('/api/v1/')) return false; return true; } function originAllowed(request: NextRequest): boolean { const origin = request.headers.get('origin'); const referer = request.headers.get('referer'); // Compare HOSTS, not full origins. TLS terminates at the reverse proxy, // so the upstream request the app sees is http://127.0.0.1 — its // protocol is unreliable, and request.nextUrl.origin reads `http` while // the browser's Origin is `https`, which would reject every same-site // mutation in production. The Host header is preserved across the proxy, // and a matching host is what same-origin CSRF defense actually needs // (a cross-site attacker can't forge the browser-set Origin host). const hostOf = (value: string | null): string | null => { if (!value) return null; try { return new URL(value).host; } catch { return null; } }; // Acceptable hosts. Behind the TLS-terminating proxy request.nextUrl.host // can be the upstream bind (127.0.0.1:PORT) rather than the public host, // so it can't be the sole source of truth. The Host header is forwarded // verbatim by nginx (`proxy_set_header Host $host`), and APP_URL is the // canonical configured origin — trust those too. Comparing hosts (not // full origins) is intentional: TLS terminates upstream so the protocol // is unreliable, and a matching host is what CSRF defense needs. const allowedHosts = new Set( [request.headers.get('host'), hostOf(process.env.APP_URL ?? null), request.nextUrl.host].filter( (h): h is string => Boolean(h), ), ); const candidate = origin ? hostOf(origin) : referer ? hostOf(referer) : null; if (candidate !== null) return allowedHosts.has(candidate); // Neither header present: most browser fetches always send Origin on // POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side // call (e.g. Next.js internal fetch). Allow. return true; } /** * Apply per-request CSP to a NextResponse. Skipped for API routes * (they don't render HTML so script-src is irrelevant) and for * already-built responses that have set their own CSP (e.g. redirects * where the static next.config CSP applies). */ function applyCsp(response: NextResponse, nonce: string, pathname: string): NextResponse { if (isApiRoute(pathname)) return response; const isProd = process.env.NODE_ENV === 'production'; response.headers.set('Content-Security-Policy', buildCspWithNonce(nonce, isProd)); response.headers.set('x-nonce', nonce); return response; } export function proxy(request: NextRequest): NextResponse { const { pathname } = request.nextUrl; // Mint a per-request nonce up-front so HTML responses can carry it. // Cheap (one UUID + base64) so we always do it; the apply step // skips API routes that don't need it. const nonce = generateNonce(); const isProd = process.env.NODE_ENV === 'production'; // CSRF defense-in-depth: state-changing requests to authed /api/v1 // endpoints must come from the app's own origin. Skipped in dev so // LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while // a Mac browser tab is loaded from localhost) doesn't trip on the // origin mismatch. Production keeps the check. if ( process.env.NODE_ENV !== 'development' && STATE_CHANGING_METHODS.has(request.method) && isOriginCheckedPath(pathname) && !originAllowed(request) ) { return NextResponse.json( { error: 'Cross-origin state-changing request rejected' }, { status: 403 }, ); } // Always allow public paths through if (isPublicPath(pathname)) { // Forward the nonce in the REQUEST header so Next's RSC bootstrap // can read it via `headers()` and stamp it onto every inline //