diff --git a/src/proxy.ts b/src/proxy.ts index d22496ca..c141d567 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -132,7 +132,6 @@ function originAllowed(request: NextRequest): boolean { // 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 expectedHost = request.nextUrl.host; const hostOf = (value: string | null): string | null => { if (!value) return null; try { @@ -141,8 +140,20 @@ function originAllowed(request: NextRequest): boolean { return null; } }; - if (origin) return hostOf(origin) === expectedHost; - if (referer) return hostOf(referer) === expectedHost; + // 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.