From 86bb8d1cc1b603ad065bf3a17305bdc8425ef1bd Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Mon, 17 Aug 2026 00:58:23 +0700 Subject: [PATCH] fix: accept same-origin requests behind a scheme-rewriting proxy A relay can report the external scheme in x-forwarded-proto while rewriting Origin onto the backend authority. Next.js then derives https from the forwarded header while Origin still says http, the two differ by scheme alone, and every POST is rejected with 403 Untrusted API request. Accept that pairing only when a proxy is in front and the Origin authority still equals the Host header, which a cross-origin page cannot forge. Direct requests keep the exact-origin comparison. Fixes #497 --- lib/request-security.test.mjs | 74 +++++++++++++++++++++++++++++++++++ lib/request-security.ts | 42 +++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/lib/request-security.test.mjs b/lib/request-security.test.mjs index 9a4e8195f..d265814a3 100644 --- a/lib/request-security.test.mjs +++ b/lib/request-security.test.mjs @@ -190,3 +190,77 @@ test("recognizes JSON request content types", async () => { headers: { "content-type": "text/plain" }, })), false); }); + +test("allows a proxy that reports the scheme out-of-band and rewrites Origin", async () => { + const { isApiRequestAllowed } = await loadSubject(); + // Azure Dev Tunnels normalizes Host and Origin onto the backend authority + // but keeps the external scheme in x-forwarded-proto. + const request = new Request("https://localhost:30141/api/test", { + method: "POST", + headers: { + host: "localhost:30141", + origin: "http://localhost:30141", + "x-forwarded-proto": "https", + "sec-fetch-site": "same-origin", + }, + }); + assert.equal(isApiRequestAllowed(request), true); +}); + +test("still rejects a foreign origin when a proxy is in front", async () => { + const { isApiRequestAllowed } = await loadSubject(); + const foreignHost = new Request("https://localhost:30141/api/test", { + method: "POST", + headers: { + host: "localhost:30141", + origin: "http://attacker.example", + "x-forwarded-proto": "https", + "sec-fetch-site": "same-site", + }, + }); + const foreignPort = new Request("https://localhost:30141/api/test", { + method: "POST", + headers: { + host: "localhost:30141", + origin: "http://localhost:30142", + "x-forwarded-proto": "https", + "sec-fetch-site": "same-site", + }, + }); + const alternateLoopback = new Request("https://localhost:30141/api/test", { + method: "POST", + headers: { + host: "localhost:30141", + origin: "http://127.0.0.1:30141", + "x-forwarded-proto": "https", + "sec-fetch-site": "same-site", + }, + }); + const opaque = new Request("https://localhost:30141/api/test", { + method: "POST", + headers: { + host: "localhost:30141", + origin: "null", + "x-forwarded-proto": "https", + "sec-fetch-site": "same-site", + }, + }); + + assert.equal(isApiRequestAllowed(foreignHost), false); + assert.equal(isApiRequestAllowed(foreignPort), false); + assert.equal(isApiRequestAllowed(alternateLoopback), false); + assert.equal(isApiRequestAllowed(opaque), false); +}); + +test("does not relax the scheme without a proxy in front", async () => { + const { isApiRequestAllowed } = await loadSubject(); + const request = new Request("https://localhost:30141/api/test", { + method: "POST", + headers: { + host: "localhost:30141", + origin: "http://localhost:30141", + "sec-fetch-site": "same-origin", + }, + }); + assert.equal(isApiRequestAllowed(request), false); +}); diff --git a/lib/request-security.ts b/lib/request-security.ts index 5d6d35da9..90bd3aebb 100644 --- a/lib/request-security.ts +++ b/lib/request-security.ts @@ -20,6 +20,20 @@ function hostnameFromAuthority(value: string): string | null { } } +function normalizeAuthority(value: string): string | null { + if (!value || /[\s/@\\]/.test(value)) return null; + try { + const parsed = new URL(`http://${value}`); + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) { + return null; + } + const hostname = normalizeHostname(parsed.hostname); + return parsed.port ? `${hostname}:${parsed.port}` : hostname; + } catch { + return null; + } +} + function normalizeConfiguredHostname(value: string | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) return null; @@ -87,6 +101,30 @@ export function isApiRequestHostAllowed( ); } +/** + * A relay can report the external scheme in `x-forwarded-proto` while rewriting + * `Origin` onto the backend authority, so the two disagree on the scheme alone + * for a request that really is same-origin (Azure Dev Tunnels does this). Accept + * that pairing only when the Origin's authority still equals the Host header, + * which a cross-origin page cannot forge, and only when a proxy is in front. + */ +function isProxyRewrittenSameOrigin(request: Request, origin: string): boolean { + if (!request.headers.get("x-forwarded-proto")) return false; + + const host = request.headers.get("host"); + if (!host) return false; + + let originHost: string; + try { + originHost = new URL(origin).host; + } catch { + return false; + } + + const originAuthority = normalizeAuthority(originHost); + return originAuthority !== null && originAuthority === normalizeAuthority(host); +} + /** Reject browser cross-site API requests while preserving non-browser clients. */ export function isApiRequestOriginAllowed(request: Request): boolean { const origin = request.headers.get("origin"); @@ -95,7 +133,9 @@ export function isApiRequestOriginAllowed(request: Request): boolean { if (!origin) return true; const requestOrigin = getRequestOrigin(request); - return requestOrigin !== null && canonicalOrigin(origin) === requestOrigin; + if (requestOrigin !== null && canonicalOrigin(origin) === requestOrigin) return true; + + return isProxyRewrittenSameOrigin(request, origin); } export function shouldCheckApiRequestOrigin(request: Request): boolean {