From 72028a7f3242d59c1264aa77a4a086cee1359f56 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 04:10:57 +0200 Subject: [PATCH] fix(proxy): trust forwarded Host header for CSRF origin check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt compared the Origin host against request.nextUrl.host, but behind the custom-server + reverse-proxy setup nextUrl.host does NOT resolve to the public host (mutations stayed 403 in prod). Accept the Origin/Referer host if it matches ANY of: the forwarded Host header (nginx sets `proxy_set_header Host $host` → crm.portnimara.com), APP_URL's host, or nextUrl.host. The Host header is the reliable source here. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/proxy.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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.