From 4711cae8e5fba8405996983d4ddec67616165436 Mon Sep 17 00:00:00 2001 From: chilung Date: Tue, 8 Sep 2026 20:31:38 +0000 Subject: [PATCH 1/3] fix(oauth): restrict xAI discovery endpoint host and reject userinfo Pin xAI OAuth discovery endpoint validation to known trusted hosts (auth.x.ai, accounts.x.ai) and reject endpoints containing embedded userinfo. Closes #4048 --- src/oauth/xai.ts | 11 ++- .../xai/xai-endpoint-validation.test.ts | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/providers/xai/xai-endpoint-validation.test.ts diff --git a/src/oauth/xai.ts b/src/oauth/xai.ts index f876b18b38..c32e72451a 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -44,10 +44,17 @@ 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"))) { + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + !TRUSTED_XAI_AUTH_HOSTS.has(host) + ) { throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`); } return parsed.toString(); 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..364303d681 --- /dev/null +++ b/tests/providers/xai/xai-endpoint-validation.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + discoverXaiOAuthEndpoints, + validateXaiEndpoint, + XAI_OAUTH_DISCOVERY_URL, +} 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/ + ); + }); +}); + From c48b802cf3d4329d3fb0fe60cd83656b17aac806 Mon Sep 17 00:00:00 2001 From: chilung Date: Tue, 8 Sep 2026 21:32:30 +0000 Subject: [PATCH 2/3] fix(oauth): sanitize unexpected endpoint in error message and remove unused test import --- src/oauth/xai.ts | 3 ++- tests/providers/xai/xai-endpoint-validation.test.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/oauth/xai.ts b/src/oauth/xai.ts index c32e72451a..600e4a24e4 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -55,7 +55,8 @@ export function validateXaiEndpoint(rawUrl: string): string { parsed.password || !TRUSTED_XAI_AUTH_HOSTS.has(host) ) { - throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`); + 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/providers/xai/xai-endpoint-validation.test.ts b/tests/providers/xai/xai-endpoint-validation.test.ts index 364303d681..b48458ef51 100644 --- a/tests/providers/xai/xai-endpoint-validation.test.ts +++ b/tests/providers/xai/xai-endpoint-validation.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { discoverXaiOAuthEndpoints, validateXaiEndpoint, - XAI_OAUTH_DISCOVERY_URL, } from "../../../src/oauth/xai"; const originalFetch = globalThis.fetch; From d78aee299b7d30f52fc4ae8d71f09288cd31a078 Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 9 Sep 2026 00:27:33 +0000 Subject: [PATCH 3/3] test(layout): register xai-endpoint-validation in test layout mapping --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + 2 files changed, 2 insertions(+) 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/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",