From 55a32af76ab158b7633f32b9708b25db1861c34e Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Mon, 12 Oct 2026 19:42:31 +0900 Subject: [PATCH] fix(security): keep EdgeOne cookies and the access code on this site Two issues from before the provider refactor, found by its review. The redirect guard dropped Authorization, cookies and the providers' key headers when a redirect left the origin, but kept x-access-code, this site's own access code; it is dropped too now. edgeOneEndpoint took the request's Origin header as the site's address. Behind a proxy the request URL is the internal one and Origin is the site's, so Origin still wins, but only when its host matches Host or X-Forwarded-Host; a request carrying another site's Origin now gets the request's own origin, so the EdgeOne cookies and the access code never travel to that site. --- lib/ai-providers.ts | 29 ++++++++++++++++++++++++--- lib/ssrf-protection.ts | 7 ++++--- tests/unit/chat-route-edgeone.test.ts | 25 +++++++++++++++++++++++ tests/unit/ssrf-protection.test.ts | 8 +++++++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 1dc48f71..264a2f35 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -1154,11 +1154,34 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { /** * The deployment's EdgeOne Pages function, as an absolute URL (the SDK * needs one). EdgeOne serves functions by their folder from the site root, - * so Next's base path does not apply. + * so Next's base path does not apply. Behind a proxy req.url is the internal + * address and the browser's Origin header is the site's, so Origin wins, + * but only for the host the request was addressed to: another site's page + * sends its own Origin, and the request's cookies must not go there. */ export function edgeOneEndpoint(req: Request): string { - const origin = req.headers.get("origin") || new URL(req.url).origin - return `${origin}/api/edgeai` + const own = new URL(req.url) + const origin = req.headers.get("origin") + const originHost = origin ? hostOf(origin) : undefined + const addressed = [ + req.headers.get("x-forwarded-host"), + req.headers.get("host"), + ].filter((h): h is string => !!h) + // Without Host headers (tests, odd runtimes) Origin is all there is + const trusted = + !!originHost && + (addressed.length === 0 || + addressed.includes(originHost) || + originHost === own.host) + return `${trusted ? origin : own.origin}/api/edgeai` +} + +function hostOf(url: string): string | undefined { + try { + return new URL(url).host + } catch { + return undefined + } } /** diff --git a/lib/ssrf-protection.ts b/lib/ssrf-protection.ts index 20f2e191..152df973 100644 --- a/lib/ssrf-protection.ts +++ b/lib/ssrf-protection.ts @@ -126,9 +126,9 @@ export class RedirectRefusedError extends Error { const MAX_REDIRECTS = 5 -// Dropped when a redirect goes to another origin: those fetch drops, and -// the key headers of providers that do not use Authorization (Anthropic, -// Google, Azure) +// Dropped when a redirect goes to another origin: those fetch drops, the +// key headers of providers that do not use Authorization (Anthropic, +// Google, Azure) and this site's access code (EdgeOne's function checks it) const CREDENTIAL_HEADERS = [ "authorization", "proxy-authorization", @@ -136,6 +136,7 @@ const CREDENTIAL_HEADERS = [ "x-api-key", "x-goog-api-key", "api-key", + "x-access-code", ] /** diff --git a/tests/unit/chat-route-edgeone.test.ts b/tests/unit/chat-route-edgeone.test.ts index 9b359d86..9bc35c70 100644 --- a/tests/unit/chat-route-edgeone.test.ts +++ b/tests/unit/chat-route-edgeone.test.ts @@ -126,3 +126,28 @@ describe("EdgeOne endpoints", () => { ) }) }) + +describe("the EdgeOne endpoint's origin", () => { + it("takes the browser's Origin for the host the request was addressed to", async () => { + vi.stubEnv("AI_PROVIDER", "edgeone") + vi.stubEnv("AI_MODEL", "@tx/deepseek-ai/deepseek-v3-0324") + // Behind a proxy the request URL is internal; Origin names the site + await send({ origin: "https://draw.example", host: "draw.example" }) + expect(calls[0]?.url).toBe( + "https://draw.example/api/edgeai/chat/completions", + ) + }) + + it("ignores another site's Origin, so the cookies stay on this site", async () => { + vi.stubEnv("AI_PROVIDER", "edgeone") + vi.stubEnv("AI_MODEL", "@tx/deepseek-ai/deepseek-v3-0324") + await send({ + origin: "https://attacker.example", + host: "localhost", + cookie: "eo_token=t", + }) + expect(calls[0]?.url).toBe( + "http://localhost/api/edgeai/chat/completions", + ) + }) +}) diff --git a/tests/unit/ssrf-protection.test.ts b/tests/unit/ssrf-protection.test.ts index 5b618724..b4df50eb 100644 --- a/tests/unit/ssrf-protection.test.ts +++ b/tests/unit/ssrf-protection.test.ts @@ -179,6 +179,7 @@ describe("redirectGuardedFetch with the quota on", () => { "x-api-key": "anthropic-key", "x-goog-api-key": "google-key", "api-key": "azure-key", + "x-access-code": "site-code", "Content-Type": "application/json", }, }) @@ -194,7 +195,12 @@ describe("redirectGuardedFetch with the quota on", () => { expect(sent(0).get("authorization")).toBe("Bearer user-key") expect(sent(1).get("authorization")).toBeNull() expect(sent(1).get("cookie")).toBeNull() - for (const name of ["x-api-key", "x-goog-api-key", "api-key"]) { + for (const name of [ + "x-api-key", + "x-goog-api-key", + "api-key", + "x-access-code", + ]) { expect(sent(1).get(name)).toBeNull() } expect(sent(1).get("content-type")).toBe("application/json")