From abc01942197d70163be212592dc4631ddf83d6d9 Mon Sep 17 00:00:00 2001 From: Xyra Sinclair Date: Sat, 18 Jul 2026 20:29:02 -0700 Subject: [PATCH] fix(proxy): never route an sk-ant bearer to the OpenAI upstream (#49) Prevent Anthropic bearer tokens from being forwarded to OpenAI when routing /v1/models. Add tests for Anthropic, OpenAI, and x-api-key routing. --- src/core/proxy.ts | 9 ++++- tests/models-auth-routing.test.ts | 67 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/models-auth-routing.test.ts diff --git a/src/core/proxy.ts b/src/core/proxy.ts index 8733e0f..f6e67f1 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -564,7 +564,14 @@ function isOpenAIResponsesPath(pathname: string): boolean { function isCanonicalOpenAIPath(pathname: string, headers: Headers, hasOpenAIKey: boolean): boolean { const isModelsPath = pathname === '/v1/models' || pathname.startsWith('/v1/models/'); - const looksOpenAIAuth = hasOpenAIKey || (headers.has('authorization') && !headers.has('x-api-key')); + // `/v1/models` exists on BOTH APIs, so it is routed by auth style — but an + // `sk-ant-…` bearer is Anthropic by construction (Claude Code subscription + // auth sends `authorization: Bearer sk-ant-oat01-…` with no x-api-key). + // Without this check that OAuth token would be forwarded to the OpenAI + // upstream: a credential leak, and a guaranteed 401. + const bearerIsAnthropic = /^Bearer\s+sk-ant-/i.test(headers.get('authorization') ?? ''); + const looksOpenAIAuth = + hasOpenAIKey || (headers.has('authorization') && !headers.has('x-api-key') && !bearerIsAnthropic); return pathname === '/v1/chat/completions' || pathname === '/v1/responses' || pathname.startsWith('/v1/responses/') diff --git a/tests/models-auth-routing.test.ts b/tests/models-auth-routing.test.ts new file mode 100644 index 0000000..5a86843 --- /dev/null +++ b/tests/models-auth-routing.test.ts @@ -0,0 +1,67 @@ +/** + * /v1/models routing by auth style. The path exists on BOTH APIs, so the + * proxy sniffs the auth header — but an `sk-ant-…` bearer is Anthropic by + * construction (Claude Code subscription auth sends + * `authorization: Bearer sk-ant-oat01-…` with no x-api-key). It must never + * be forwarded to the OpenAI upstream. All tokens here are fake; the suite + * never touches the network (global fetch is stubbed). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { createProxy } from '../src/core/proxy.js'; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stubFetch(capture: { url?: string; headers?: Headers }) { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capture.url = String(input); + capture.headers = new Headers(init?.headers); + return new Response(JSON.stringify({ data: [] }), { + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; +} + +describe('/v1/models auth-style routing', () => { + const proxy = () => createProxy({}); + + it('routes an sk-ant bearer (Claude Code OAuth) to the Anthropic upstream', async () => { + const cap: { url?: string; headers?: Headers } = {}; + stubFetch(cap); + await proxy()( + new Request('http://localhost/v1/models', { + method: 'GET', + headers: { authorization: 'Bearer sk-ant-oat01-fake-oauth-token' }, + }), + ); + expect(cap.url).toBe('https://api.anthropic.com/v1/models'); + // The token stays on the Anthropic leg, never crosses to api.openai.com. + expect(cap.headers?.get('authorization')).toBe('Bearer sk-ant-oat01-fake-oauth-token'); + }); + + it('still routes a non-Anthropic bearer to the OpenAI upstream', async () => { + const cap: { url?: string; headers?: Headers } = {}; + stubFetch(cap); + await proxy()( + new Request('http://localhost/v1/models', { + method: 'GET', + headers: { authorization: 'Bearer fake-openai-key' }, + }), + ); + expect(cap.url).toBe('https://api.openai.com/v1/models'); + }); + + it('still routes x-api-key requests to the Anthropic upstream', async () => { + const cap: { url?: string; headers?: Headers } = {}; + stubFetch(cap); + await proxy()( + new Request('http://localhost/v1/models', { + method: 'GET', + headers: { 'x-api-key': 'fake-anthropic-key' }, + }), + ); + expect(cap.url).toBe('https://api.anthropic.com/v1/models'); + }); +});