diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 53a8e65898..5aa8568c24 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1298,6 +1298,7 @@ "ws-upstream-reuse.test.ts": "responses", "ws-upstream.test.ts": "responses", "xai-client.test.ts": "images", + "xai-endpoint-validation.test.ts": "providers/xai", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", "xai-tool-schema.test.ts": "providers/xai", diff --git a/src/oauth/xai.ts b/src/oauth/xai.ts index f876b18b38..600e4a24e4 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -44,11 +44,19 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal { return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; } -function validateXaiEndpoint(rawUrl: string): string { +const TRUSTED_XAI_AUTH_HOSTS = new Set(["auth.x.ai", "accounts.x.ai"]); + +export function validateXaiEndpoint(rawUrl: string): string { const parsed = new URL(rawUrl); const host = parsed.hostname.toLowerCase(); - if (parsed.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) { - throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`); + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + !TRUSTED_XAI_AUTH_HOSTS.has(host) + ) { + const sanitized = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`; + throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${sanitized}`); } return parsed.toString(); } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index da6be012bc..5bc3e80ebb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1133,6 +1133,7 @@ "ws-upstream-reuse.test.ts": "responses", "ws-upstream.test.ts": "responses", "xai-client.test.ts": "images", + "xai-endpoint-validation.test.ts": "providers/xai", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", "xai-tool-schema.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-endpoint-validation.test.ts b/tests/providers/xai/xai-endpoint-validation.test.ts new file mode 100644 index 0000000000..b48458ef51 --- /dev/null +++ b/tests/providers/xai/xai-endpoint-validation.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + discoverXaiOAuthEndpoints, + validateXaiEndpoint, +} from "../../../src/oauth/xai"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("xAI endpoint validation (#4048)", () => { + test("allows trusted auth.x.ai and accounts.x.ai endpoints", () => { + expect(validateXaiEndpoint("https://auth.x.ai/oauth2/token")).toBe( + "https://auth.x.ai/oauth2/token" + ); + expect(validateXaiEndpoint("https://accounts.x.ai/oauth2/token")).toBe( + "https://accounts.x.ai/oauth2/token" + ); + expect(validateXaiEndpoint("https://AUTH.X.AI/token")).toBe( + "https://auth.x.ai/token" + ); + }); + + test("rejects arbitrary x.ai subdomains and root domain", () => { + expect(() => validateXaiEndpoint("https://evil.x.ai/token")).toThrow( + /unexpected endpoint/ + ); + expect(() => validateXaiEndpoint("https://anything.x.ai/token")).toThrow( + /unexpected endpoint/ + ); + expect(() => validateXaiEndpoint("https://x.ai/token")).toThrow( + /unexpected endpoint/ + ); + expect(() => validateXaiEndpoint("https://api.x.ai/token")).toThrow( + /unexpected endpoint/ + ); + }); + + test("rejects endpoints with embedded userinfo", () => { + expect(() => + validateXaiEndpoint(["https://user:pass", "@", "auth.x.ai/token"].join("")) + ).toThrow(/unexpected endpoint/); + expect(() => + validateXaiEndpoint(["https://user", "@", "auth.x.ai/token"].join("")) + ).toThrow(/unexpected endpoint/); + expect(() => + validateXaiEndpoint(["https://:pass", "@", "auth.x.ai/token"].join("")) + ).toThrow(/unexpected endpoint/); + }); + + test("rejects non-https schemes and non-x.ai domains", () => { + expect(() => validateXaiEndpoint("http://auth.x.ai/token")).toThrow( + /unexpected endpoint/ + ); + expect(() => validateXaiEndpoint("https://attacker.com/token")).toThrow( + /unexpected endpoint/ + ); + expect(() => + validateXaiEndpoint("https://auth.x.ai.attacker.com/token") + ).toThrow(/unexpected endpoint/); + }); + + test("discoverXaiOAuthEndpoints rejects untrusted discovery endpoints", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + authorization_endpoint: "https://auth.x.ai/oauth2/auth", + token_endpoint: "https://evil.x.ai/oauth2/token", + }) + )) as typeof fetch; + + await expect(discoverXaiOAuthEndpoints()).rejects.toThrow( + /unexpected endpoint/ + ); + }); +}); +