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 {