Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions lib/request-security.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
42 changes: 41 additions & 1 deletion lib/request-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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 {
Expand Down