From d2493a73810f0c5391e3cdabe6b025fa953c0b4e Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Thu, 23 Jul 2026 13:01:43 -0600 Subject: [PATCH] fix(ai-mistral): require clean api base urls --- packages/ai/mistral/src/index.test.ts | 14 ++++++++++++++ packages/ai/mistral/src/index.ts | 25 ++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/ai/mistral/src/index.test.ts b/packages/ai/mistral/src/index.test.ts index 1899498b..8a51a657 100644 --- a/packages/ai/mistral/src/index.test.ts +++ b/packages/ai/mistral/src/index.test.ts @@ -81,6 +81,20 @@ describe('Mistral 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/mistral/src/index.ts b/packages/ai/mistral/src/index.ts index 2b4ab799..9947e5f4 100644 --- a/packages/ai/mistral/src/index.ts +++ b/packages/ai/mistral/src/index.ts @@ -6,8 +6,31 @@ interface Config { const DEFAULT_BASE = 'https://api.mistral.ai'; +function cleanBaseUrl(baseUrl?: string): string { + if (!baseUrl) return DEFAULT_BASE; + + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + throw new Error('Mistral baseUrl must be a valid URL'); + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('Mistral baseUrl must use http or https'); + } + if (parsed.username || parsed.password) { + throw new Error('Mistral baseUrl must not include credentials'); + } + if (parsed.search || parsed.hash) { + throw new Error('Mistral 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 {