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")