import { NextResponse } from "next/server"; import { auth } from "./server/auth-edge.js"; // UI routes that are accessible without a session (login page, reset flow, // public invite acceptance). All other UI routes redirect unauthenticated // visitors to /auth/signin. const PUBLIC_UI_PREFIXES = ["/auth/", "/invite/"]; // API allowlist — only routes listed here are served. Everything else under // `/api/*` returns 404. Each allowlisted route MUST perform its own // authentication (session check via auth(), CRON_SECRET bearer header, etc.) // because the edge middleware cannot do Node-only work like Prisma queries. // Prefix entries must end with `/`; exact entries match only the literal // pathname. A new /api route therefore requires a deliberate allowlist edit, // preventing accidental default-public exposure (security ticket #44). export const SELF_AUTH_API_PREFIXES = [ "/api/auth/", "/api/trpc/", "/api/sse/", "/api/cron/", "/api/reports/", ]; export const SELF_AUTH_API_EXACT = ["/api/health", "/api/ready", "/api/perf"]; export function isApiAllowlisted(pathname: string): boolean { if (SELF_AUTH_API_EXACT.includes(pathname)) return true; return SELF_AUTH_API_PREFIXES.some((p) => pathname.startsWith(p)); } function isPublicUiPath(pathname: string): boolean { return PUBLIC_UI_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } // Browser-side code never talks to AI providers directly — every OpenAI / // Azure / Gemini call goes through a server tRPC route. Therefore connect-src // is locked to 'self' with no wildcards (ticket #45). If a future feature // needs a browser-originated cross-origin request, add it explicitly here. function buildCsp(nonce: string, isProd: boolean): string { const scriptSrc = isProd ? `'self' 'nonce-${nonce}'` : `'self' 'unsafe-eval' 'unsafe-inline'`; const imgSrc = isProd ? "'self' data: blob:" : "'self' data: blob: https:"; return [ "default-src 'self'", `script-src ${scriptSrc}`, // style-src keeps 'unsafe-inline' because React inlines styles from // component-scoped CSS and @react-pdf/renderer emits inline style blocks. // A nonce-based style-src-elem breaks both. This is an accepted residual // risk documented in docs/security-architecture.md §5. "style-src 'self' 'unsafe-inline'", `img-src ${imgSrc}`, "font-src 'self' data:", "connect-src 'self'", "frame-ancestors 'none'", "frame-src 'none'", "object-src 'none'", "media-src 'self'", "worker-src 'self' blob:", "base-uri 'self'", "form-action 'self'", ].join("; "); } export default auth(function middleware(request) { const { pathname } = request.nextUrl; // /api/* — default-deny. Only allowlisted routes pass; everything else 404s. // Allowlisted routes are responsible for their own auth check (they are // reached in the route handler, not here, because edge middleware cannot do // Prisma queries). if (pathname.startsWith("/api/")) { if (!isApiAllowlisted(pathname)) { return NextResponse.json({ error: "Not Found" }, { status: 404 }); } // fall through — continue to add CSP headers } else if (!isPublicUiPath(pathname) && !request.auth) { // UI route requires a session. Redirect to signin. const signInUrl = new URL("/auth/signin", request.url); signInUrl.searchParams.set("callbackUrl", request.url); return NextResponse.redirect(signInUrl); } // Generate a cryptographically random nonce for this request const nonceBytes = new Uint8Array(16); crypto.getRandomValues(nonceBytes); const nonce = btoa(String.fromCharCode(...nonceBytes)); const isProd = process.env.NODE_ENV === "production"; const csp = buildCsp(nonce, isProd); // Forward nonce to server components via request header const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set("Content-Security-Policy", csp); const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set("Content-Security-Policy", csp); return response; }); export const config = { matcher: [ // Apply to all routes except Next.js internals and static assets "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };