diff --git a/packages/ai/claude/src/index.test.ts b/packages/ai/claude/src/index.test.ts index 535ce300..a694c673 100644 --- a/packages/ai/claude/src/index.test.ts +++ b/packages/ai/claude/src/index.test.ts @@ -61,7 +61,7 @@ describe('Claude Messages API generation', () => { temperature: 0.2, extra: { top_p: 0.9 }, }, - { baseUrl: 'https://anthropic.test' }, + { baseUrl: 'https://anthropic.test/' }, ); expect(fetchMock).toHaveBeenCalledOnce(); @@ -88,6 +88,22 @@ describe('Claude Messages API generation', () => { }); }); + it.each([ + ['missing scheme', 'anthropic.test'], + ['ftp scheme', 'ftp://anthropic.test'], + ['credentials', 'https://user:pass@anthropic.test'], + ['query string', 'https://anthropic.test?token=secret'], + ['fragment', 'https://anthropic.test#messages'], + ])('rejects unclean custom baseUrl values: %s', async (_label, baseUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.generate(ctx(), 'hello', {}, { baseUrl })).rejects.toThrow( + /Anthropic baseUrl/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('uses a safe default max_tokens value when not provided', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/ai/claude/src/index.ts b/packages/ai/claude/src/index.ts index 6eed6a28..9060a04c 100644 --- a/packages/ai/claude/src/index.ts +++ b/packages/ai/claude/src/index.ts @@ -23,7 +23,8 @@ export default defineAi({ ctx.log(`claude · model=${model} · ${prompt.length} chars in`); if (ctx.dryRun) return { text: '[dry-run]', model }; - const res = await fetch(`${config.baseUrl ?? DEFAULT_BASE}/v1/messages`, { + const baseUrl = cleanBaseUrl(config.baseUrl ?? DEFAULT_BASE); + const res = await fetch(`${baseUrl}/v1/messages`, { method: 'POST', headers: { 'x-api-key': apiKey, @@ -71,3 +72,22 @@ function redact(value: string, apiKey: string): string { .replaceAll(apiKey, '[redacted]') .replace(/sk-ant-[A-Za-z0-9._~+/=-]{12,}/g, '[redacted]'); } + +function cleanBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Anthropic baseUrl must be a valid URL'); + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Anthropic baseUrl must use http or https'); + } + + if (url.username || url.password || url.search || url.hash) { + throw new Error('Anthropic baseUrl must be a clean API base without credentials, query, or hash'); + } + + return url.toString().replace(/\/+$/, ''); +}