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(). '/api/v1/bootstrap/', '/scan', '/portal/', '/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'); // Same-origin fetch from the app sends both Origin AND a matching host. // Use request.nextUrl.origin (the deployed origin) as the source of truth. const expectedOrigin = request.nextUrl.origin; if (origin) return origin === expectedOrigin; if (referer) { try { return new URL(referer).origin === expectedOrigin; } catch { return false; } } // 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 //