Skip to content

Commit 48f6e05

Browse files
committed
Stop canonical Anthropic headers from leaking into 3P shim requests
The remaining blocker from PR Gitlawb#268 was that canonical Anthropic headers such as `anthropic-version` and `anthropic-beta` could still ride through supported 3P paths even after the earlier x-anthropic/x-claude scrubber work. This tightens header filtering inside the shim itself so direct defaultHeaders, env-driven client setup, providerOverride routing, and per-request header injection all share the same scrubber. Constraint: Preserve non-Anthropic custom headers and provider auth while stripping only Anthropic/OpenClaude-internal headers from 3P requests Rejected: Rely on client.ts filtering alone | direct shim construction and per-request headers would still leave gaps Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep header scrubbing centralized in the shim so new call paths do not reopen 3P leakage bugs Tested: bun test src/services/api/openaiShim.test.ts src/services/api/client.test.ts src/utils/context.test.ts Tested: bun run test:provider Tested: bun run build && node dist/cli.mjs --version Not-tested: bun run typecheck (repository baseline currently fails in many unrelated files)
1 parent afc58c2 commit 48f6e05

3 files changed

Lines changed: 297 additions & 37 deletions

File tree

src/services/api/client.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type ShimClient = {
1414
const originalFetch = globalThis.fetch
1515
const originalMacro = (globalThis as Record<string, unknown>).MACRO
1616
const originalEnv = {
17+
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
1718
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
1819
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
1920
GEMINI_MODEL: process.env.GEMINI_MODEL,
@@ -24,6 +25,7 @@ const originalEnv = {
2425
OPENAI_MODEL: process.env.OPENAI_MODEL,
2526
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
2627
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
28+
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
2729
}
2830

2931
beforeEach(() => {
@@ -33,16 +35,19 @@ beforeEach(() => {
3335
process.env.GEMINI_MODEL = 'gemini-2.0-flash'
3436
process.env.GEMINI_BASE_URL = 'https://gemini.example/v1beta/openai'
3537

38+
delete process.env.CLAUDE_CODE_USE_OPENAI
3639
delete process.env.GOOGLE_API_KEY
3740
delete process.env.OPENAI_API_KEY
3841
delete process.env.OPENAI_BASE_URL
3942
delete process.env.OPENAI_MODEL
4043
delete process.env.ANTHROPIC_API_KEY
4144
delete process.env.ANTHROPIC_AUTH_TOKEN
45+
delete process.env.ANTHROPIC_CUSTOM_HEADERS
4246
})
4347

4448
afterEach(() => {
4549
;(globalThis as Record<string, unknown>).MACRO = originalMacro
50+
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
4651
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
4752
process.env.GEMINI_API_KEY = originalEnv.GEMINI_API_KEY
4853
process.env.GEMINI_MODEL = originalEnv.GEMINI_MODEL
@@ -53,6 +58,7 @@ afterEach(() => {
5358
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
5459
process.env.ANTHROPIC_API_KEY = originalEnv.ANTHROPIC_API_KEY
5560
process.env.ANTHROPIC_AUTH_TOKEN = originalEnv.ANTHROPIC_AUTH_TOKEN
61+
process.env.ANTHROPIC_CUSTOM_HEADERS = originalEnv.ANTHROPIC_CUSTOM_HEADERS
5662
globalThis.fetch = originalFetch
5763
})
5864

@@ -119,3 +125,135 @@ test('routes Gemini provider requests through the OpenAI-compatible shim', async
119125
model: 'gemini-2.0-flash',
120126
})
121127
})
128+
129+
test('strips Anthropic-specific custom headers before sending OpenAI-compatible shim requests', async () => {
130+
let capturedHeaders: Headers | undefined
131+
132+
process.env.CLAUDE_CODE_USE_OPENAI = '1'
133+
process.env.OPENAI_API_KEY = 'openai-test-key'
134+
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
135+
process.env.OPENAI_MODEL = 'gpt-4o'
136+
process.env.ANTHROPIC_CUSTOM_HEADERS = [
137+
'anthropic-version: 2023-06-01',
138+
'anthropic-beta: prompt-caching-2024-07-31',
139+
'x-anthropic-additional-protection: true',
140+
'x-claude-remote-session-id: remote-123',
141+
'x-app: cli',
142+
'x-safe-header: keep-me',
143+
].join('\n')
144+
145+
globalThis.fetch = (async (_input, init) => {
146+
capturedHeaders = new Headers(init?.headers)
147+
148+
return new Response(
149+
JSON.stringify({
150+
id: 'chatcmpl-openai',
151+
model: 'gpt-4o',
152+
choices: [
153+
{
154+
message: {
155+
role: 'assistant',
156+
content: 'ok',
157+
},
158+
finish_reason: 'stop',
159+
},
160+
],
161+
usage: {
162+
prompt_tokens: 8,
163+
completion_tokens: 3,
164+
total_tokens: 11,
165+
},
166+
}),
167+
{
168+
headers: {
169+
'Content-Type': 'application/json',
170+
},
171+
},
172+
)
173+
}) as FetchType
174+
175+
const client = (await getAnthropicClient({
176+
maxRetries: 0,
177+
model: 'gpt-4o',
178+
})) as unknown as ShimClient
179+
180+
await client.beta.messages.create({
181+
model: 'gpt-4o',
182+
system: 'test system',
183+
messages: [{ role: 'user', content: 'hello' }],
184+
max_tokens: 64,
185+
stream: false,
186+
})
187+
188+
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
189+
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
190+
expect(capturedHeaders?.get('x-anthropic-additional-protection')).toBeNull()
191+
expect(capturedHeaders?.get('x-claude-remote-session-id')).toBeNull()
192+
expect(capturedHeaders?.get('x-app')).toBeNull()
193+
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
194+
expect(capturedHeaders?.get('authorization')).toBe('Bearer openai-test-key')
195+
})
196+
197+
test('strips Anthropic-specific custom headers on providerOverride shim requests too', async () => {
198+
let capturedHeaders: Headers | undefined
199+
200+
process.env.ANTHROPIC_CUSTOM_HEADERS = [
201+
'anthropic-version: 2023-06-01',
202+
'anthropic-beta: prompt-caching-2024-07-31',
203+
'x-claude-remote-session-id: remote-123',
204+
'x-safe-header: keep-me',
205+
].join('\n')
206+
207+
globalThis.fetch = (async (_input, init) => {
208+
capturedHeaders = new Headers(init?.headers)
209+
210+
return new Response(
211+
JSON.stringify({
212+
id: 'chatcmpl-provider-override',
213+
model: 'gpt-4o',
214+
choices: [
215+
{
216+
message: {
217+
role: 'assistant',
218+
content: 'ok',
219+
},
220+
finish_reason: 'stop',
221+
},
222+
],
223+
usage: {
224+
prompt_tokens: 8,
225+
completion_tokens: 3,
226+
total_tokens: 11,
227+
},
228+
}),
229+
{
230+
headers: {
231+
'Content-Type': 'application/json',
232+
},
233+
},
234+
)
235+
}) as FetchType
236+
237+
const client = (await getAnthropicClient({
238+
maxRetries: 0,
239+
providerOverride: {
240+
model: 'gpt-4o',
241+
baseURL: 'http://example.test/v1',
242+
apiKey: 'provider-test-key',
243+
},
244+
})) as unknown as ShimClient
245+
246+
await client.beta.messages.create({
247+
model: 'unused',
248+
system: 'test system',
249+
messages: [{ role: 'user', content: 'hello' }],
250+
max_tokens: 64,
251+
stream: false,
252+
})
253+
254+
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
255+
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
256+
expect(capturedHeaders?.get('x-claude-remote-session-id')).toBeNull()
257+
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
258+
expect(capturedHeaders?.get('authorization')).toBe('Bearer provider-test-key')
259+
})

src/services/api/openaiShim.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const originalEnv = {
77
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
88
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
99
OPENAI_MODEL: process.env.OPENAI_MODEL,
10+
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
1011
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
1112
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
1213
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
@@ -15,6 +16,7 @@ const originalEnv = {
1516
GEMINI_BASE_URL: process.env.GEMINI_BASE_URL,
1617
GEMINI_MODEL: process.env.GEMINI_MODEL,
1718
GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT,
19+
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
1820
}
1921

2022
const originalFetch = globalThis.fetch
@@ -70,6 +72,7 @@ beforeEach(() => {
7072
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
7173
process.env.OPENAI_API_KEY = 'test-key'
7274
delete process.env.OPENAI_MODEL
75+
delete process.env.CLAUDE_CODE_USE_OPENAI
7376
delete process.env.CLAUDE_CODE_USE_GEMINI
7477
delete process.env.GEMINI_API_KEY
7578
delete process.env.GOOGLE_API_KEY
@@ -78,12 +81,14 @@ beforeEach(() => {
7881
delete process.env.GEMINI_BASE_URL
7982
delete process.env.GEMINI_MODEL
8083
delete process.env.GOOGLE_CLOUD_PROJECT
84+
delete process.env.ANTHROPIC_CUSTOM_HEADERS
8185
})
8286

8387
afterEach(() => {
8488
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
8589
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
8690
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
91+
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
8792
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
8893
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
8994
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
@@ -92,9 +97,129 @@ afterEach(() => {
9297
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
9398
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
9499
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
100+
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
95101
globalThis.fetch = originalFetch
96102
})
97103

104+
test('strips canonical Anthropic headers from direct shim defaultHeaders', async () => {
105+
let capturedHeaders: Headers | undefined
106+
107+
globalThis.fetch = (async (_input, init) => {
108+
capturedHeaders = new Headers(init?.headers)
109+
110+
return new Response(
111+
JSON.stringify({
112+
id: 'chatcmpl-1',
113+
model: 'gpt-4o',
114+
choices: [
115+
{
116+
message: {
117+
role: 'assistant',
118+
content: 'ok',
119+
},
120+
finish_reason: 'stop',
121+
},
122+
],
123+
usage: {
124+
prompt_tokens: 8,
125+
completion_tokens: 3,
126+
total_tokens: 11,
127+
},
128+
}),
129+
{
130+
headers: {
131+
'Content-Type': 'application/json',
132+
},
133+
},
134+
)
135+
}) as FetchType
136+
137+
const client = createOpenAIShimClient({
138+
defaultHeaders: {
139+
'anthropic-version': '2023-06-01',
140+
'anthropic-beta': 'prompt-caching-2024-07-31',
141+
'x-anthropic-additional-protection': 'true',
142+
'x-claude-remote-session-id': 'remote-123',
143+
'x-app': 'cli',
144+
'x-client-app': 'sdk',
145+
'x-safe-header': 'keep-me',
146+
},
147+
}) as OpenAIShimClient
148+
149+
await client.beta.messages.create({
150+
model: 'gpt-4o',
151+
system: 'test system',
152+
messages: [{ role: 'user', content: 'hello' }],
153+
max_tokens: 64,
154+
stream: false,
155+
})
156+
157+
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
158+
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
159+
expect(capturedHeaders?.get('x-anthropic-additional-protection')).toBeNull()
160+
expect(capturedHeaders?.get('x-claude-remote-session-id')).toBeNull()
161+
expect(capturedHeaders?.get('x-app')).toBeNull()
162+
expect(capturedHeaders?.get('x-client-app')).toBeNull()
163+
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
164+
})
165+
166+
test('strips canonical Anthropic headers from per-request shim headers too', async () => {
167+
let capturedHeaders: Headers | undefined
168+
169+
globalThis.fetch = (async (_input, init) => {
170+
capturedHeaders = new Headers(init?.headers)
171+
172+
return new Response(
173+
JSON.stringify({
174+
id: 'chatcmpl-1',
175+
model: 'gpt-4o',
176+
choices: [
177+
{
178+
message: {
179+
role: 'assistant',
180+
content: 'ok',
181+
},
182+
finish_reason: 'stop',
183+
},
184+
],
185+
usage: {
186+
prompt_tokens: 8,
187+
completion_tokens: 3,
188+
total_tokens: 11,
189+
},
190+
}),
191+
{
192+
headers: {
193+
'Content-Type': 'application/json',
194+
},
195+
},
196+
)
197+
}) as FetchType
198+
199+
const client = createOpenAIShimClient({}) as OpenAIShimClient
200+
201+
await client.beta.messages.create(
202+
{
203+
model: 'gpt-4o',
204+
system: 'test system',
205+
messages: [{ role: 'user', content: 'hello' }],
206+
max_tokens: 64,
207+
stream: false,
208+
},
209+
{
210+
headers: {
211+
'anthropic-version': '2023-06-01',
212+
'anthropic-beta': 'prompt-caching-2024-07-31',
213+
'x-safe-header': 'keep-me',
214+
},
215+
},
216+
)
217+
218+
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
219+
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
220+
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
221+
})
222+
98223
test('preserves usage from final OpenAI stream chunk with empty choices', async () => {
99224
globalThis.fetch = (async (_input, init) => {
100225
const url = typeof _input === 'string' ? _input : _input.url

0 commit comments

Comments
 (0)