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
64 changes: 64 additions & 0 deletions lib/request-security.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,70 @@ test("rejects missing, malformed, and unconfigured Host headers", async () => {
})), false);
});

test("allows same-origin requests when Chromium strips the port from Origin", async () => {
const { isApiRequestAllowed } = await loadSubject();
// Loopback IP with the port stripped from Origin (Chromium 150+ behavior).
const loopback = new Request("http://127.0.0.1:30141/api/agent/running", {
headers: {
host: "127.0.0.1:30141",
origin: "http://127.0.0.1",
"sec-fetch-site": "same-origin",
},
});
assert.equal(isApiRequestAllowed(loopback), true);

// LAN IP with the port stripped from Origin.
const lan = new Request("http://192.168.32.7:30141/api/test", {
method: "POST",
headers: {
host: "192.168.32.7:30141",
origin: "http://192.168.32.7",
"sec-fetch-site": "same-origin",
"content-type": "application/json",
},
});
assert.equal(isApiRequestAllowed(lan), true);

// Loopback name with the port stripped from Origin.
const named = new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "localhost:30141",
origin: "http://localhost",
"sec-fetch-site": "same-origin",
"content-type": "application/json",
},
});
assert.equal(isApiRequestAllowed(named), true);
});

test("still rejects when Origin hostname differs from Host even with port stripped", async () => {
const { isApiRequestAllowed } = await loadSubject();
// Cross-loopback-name origin should not slip through just because both
// sides happen to be loopback addresses.
const crossName = new Request("http://localhost:30141/api/test", {
headers: {
host: "localhost:30141",
origin: "http://127.0.0.1",
"sec-fetch-site": "same-origin",
},
});
assert.equal(isApiRequestAllowed(crossName), false);

// Attacker host matching its own Origin must still be blocked by the
// Host allowlist, not the Origin comparison.
const dnsRebind = new Request("http://localhost:30141/api/skills/install", {
method: "POST",
headers: {
host: "attacker.example:30141",
origin: "http://attacker.example",
"sec-fetch-site": "same-origin",
"content-type": "application/json",
},
});
assert.equal(isApiRequestAllowed(dnsRebind), false);
});

test("recognizes JSON request content types", async () => {
const { hasJsonContentType } = await loadSubject();
assert.equal(hasJsonContentType(new Request("http://localhost", {
Expand Down
20 changes: 19 additions & 1 deletion lib/request-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,33 @@ export function isApiRequestHostAllowed(
);
}

function originHostname(value: string): string | null {
try {
return new URL(value).hostname;
} catch {
return null;
}
}

/** Reject browser cross-site API requests while preserving non-browser clients. */
export function isApiRequestOriginAllowed(request: Request): boolean {
const origin = request.headers.get("origin");
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite === "cross-site") return false;
if (!origin) return true;

// Chromium 150+ strips the port from the Origin header for same-origin
// requests on non-default ports. Strict canonical-origin comparison would
// therefore reject those legitimate requests ("http://127.0.0.1:30141"
// vs. the browser-sent "http://127.0.0.1"). The Host header is the
// authoritative source for where the request actually went, so accept any
// Origin whose hostname matches it. Hostnames are case-insensitive, which
// the URL constructor already handles for us.
const requestOrigin = getRequestOrigin(request);
return requestOrigin !== null && canonicalOrigin(origin) === requestOrigin;
if (!requestOrigin) return false;
const originHost = originHostname(origin);
const requestHost = originHostname(requestOrigin);
return originHost !== null && originHost === requestHost;
}

export function shouldCheckApiRequestOrigin(request: Request): boolean {
Expand Down