From 7242dd68b328bb0d5761c34db5a7b59edf2b01a6 Mon Sep 17 00:00:00 2001 From: LCZcoding <2481606011@qq.com> Date: Tue, 18 Aug 2026 18:17:51 +0800 Subject: [PATCH] fix(security): tolerate Chromium stripping port from Origin header Chromium 150+ omits the port from the Origin header for same-origin requests on non-default ports. The previous canonical-origin comparison (includes port) therefore rejected every legitimate pi-web API call, producing 'Error: HTTP 403' on the UI and an empty session sidebar. Compare origin hostnames only; the Host header is the authoritative source for where the request actually went, and the host allowlist still rejects DNS-rebinding and cross-loopback-name origins. Add tests covering loopback IP, LAN IP, and loopback name with the port stripped, plus regression tests for cross-name and DNS-rebind attacks. --- lib/request-security.test.mjs | 64 +++++++++++++++++++++++++++++++++++ lib/request-security.ts | 20 ++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lib/request-security.test.mjs b/lib/request-security.test.mjs index 9a4e8195f..a949c4aae 100644 --- a/lib/request-security.test.mjs +++ b/lib/request-security.test.mjs @@ -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", { diff --git a/lib/request-security.ts b/lib/request-security.ts index 5d6d35da9..55063d8d9 100644 --- a/lib/request-security.ts +++ b/lib/request-security.ts @@ -87,6 +87,14 @@ 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"); @@ -94,8 +102,18 @@ export function isApiRequestOriginAllowed(request: Request): boolean { 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 {