diff --git a/packages/ai/xai/src/index.test.ts b/packages/ai/xai/src/index.test.ts index 6c7c0784..8479a652 100644 --- a/packages/ai/xai/src/index.test.ts +++ b/packages/ai/xai/src/index.test.ts @@ -81,6 +81,20 @@ describe('xAI OpenAI-compatible generation', () => { expect(url).toBe('https://proxy.example.com/v1/chat/completions'); }); + it.each([ + ['not-a-url', 'valid URL'], + ['ftp://proxy.example.com', 'http or https'], + ['https://user:pass@proxy.example.com', 'must not include credentials'], + ['https://proxy.example.com?region=us', 'must not include query strings or fragments'], + ['https://proxy.example.com#v1', 'must not include query strings or fragments'], + ])('rejects unsafe configured base URL %s', async (baseUrl, message) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.generate(ctx(), 'hello', {}, { baseUrl })).rejects.toThrow(message); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('includes status and redacted response body excerpt on errors', async () => { const apiKey = 'test-key-crossing-truncation-boundary'; const prefix = 'x'.repeat(190); diff --git a/packages/ai/xai/src/index.ts b/packages/ai/xai/src/index.ts index ec10c272..a32098e4 100644 --- a/packages/ai/xai/src/index.ts +++ b/packages/ai/xai/src/index.ts @@ -6,8 +6,31 @@ interface Config { const DEFAULT_BASE = 'https://api.x.ai'; +function cleanBaseUrl(baseUrl?: string): string { + if (!baseUrl) return DEFAULT_BASE; + + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + throw new Error('xAI baseUrl must be a valid URL'); + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('xAI baseUrl must use http or https'); + } + if (parsed.username || parsed.password) { + throw new Error('xAI baseUrl must not include credentials'); + } + if (parsed.search || parsed.hash) { + throw new Error('xAI baseUrl must not include query strings or fragments'); + } + + return parsed.toString().replace(/\/+$/, ''); +} + function chatCompletionsUrl(baseUrl?: string): string { - return `${(baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/v1/chat/completions`; + return `${cleanBaseUrl(baseUrl)}/v1/chat/completions`; } function redact(value: string, apiKey: string): string {