fix(proxy): trust forwarded Host header for CSRF origin check
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m53s
Build & Push Docker Images / build-and-push (push) Successful in 7m22s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 04:10:57 +02:00
parent d485695357
commit 72028a7f32

View File

@@ -132,7 +132,6 @@ function originAllowed(request: NextRequest): boolean {
// mutation in production. The Host header is preserved across the proxy, // mutation in production. The Host header is preserved across the proxy,
// and a matching host is what same-origin CSRF defense actually needs // and a matching host is what same-origin CSRF defense actually needs
// (a cross-site attacker can't forge the browser-set Origin host). // (a cross-site attacker can't forge the browser-set Origin host).
const expectedHost = request.nextUrl.host;
const hostOf = (value: string | null): string | null => { const hostOf = (value: string | null): string | null => {
if (!value) return null; if (!value) return null;
try { try {
@@ -141,8 +140,20 @@ function originAllowed(request: NextRequest): boolean {
return null; return null;
} }
}; };
if (origin) return hostOf(origin) === expectedHost; // Acceptable hosts. Behind the TLS-terminating proxy request.nextUrl.host
if (referer) return hostOf(referer) === expectedHost; // 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 // Neither header present: most browser fetches always send Origin on
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side // POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
// call (e.g. Next.js internal fetch). Allow. // call (e.g. Next.js internal fetch). Allow.