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.
This commit is contained in:
Xyra Sinclair
2026-07-18 20:29:02 -07:00
committed by GitHub
parent b6c6c0358f
commit abc0194219
2 changed files with 75 additions and 1 deletions
+8 -1
View File
@@ -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/')
+67
View File
@@ -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');
});
});