mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-17 03:57:18 +00:00
fix(core)!: suppress env back-fill so proxy auth doesn't leak real Anthropic key
#4020 review (tanzhenxin, severity high): the IdeaLab-proxy branch spread `{ authToken: <key> }` and omitted `apiKey` entirely. The Anthropic SDK constructor destructures with defaults (`apiKey = readEnv('ANTHROPIC_API_KEY') ?? null`), and destructuring defaults only fire for `undefined` — so an omitted `apiKey` lets `ANTHROPIC_API_KEY` back-fill it. The SDK's auth resolver then prefers `apiKey` over `authToken`, shipping `X-Api-Key` (not `Authorization: Bearer`) on the wire. Concrete impact: a user with `ANTHROPIC_API_KEY=sk-ant-…` exported (normal for anyone also running Claude Code in the same shell) configuring qwen-code with an IdeaLab proxy plus an IdeaLab token would leak their real Anthropic key as `X-Api-Key` to the third-party proxy endpoint. - Pass `apiKey: null` explicitly on the proxy branch and `authToken: null` on the Anthropic-native branch. Explicit `null` suppresses the destructuring default; the env back-fill no longer fires. - New helper `resolveEffectiveBaseUrl` mirrors the SDK's own destructuring order (config → `ANTHROPIC_BASE_URL` env → SDK default). `isAnthropicNativeBaseUrl` now consults the env too, so a user configuring the proxy purely through `ANTHROPIC_BASE_URL` (qwen-code `baseUrl` unset) gets the proxy identity bundle instead of silently shipping native auth + UA + cache-scope beta to the proxy. Tests: - ANTHROPIC_API_KEY env + proxy baseURL → ctor receives `apiKey: null` and `authToken: our-key`. Locks in the credential-leak fix. - ANTHROPIC_AUTH_TOKEN env + Anthropic-native baseURL → ctor receives `authToken: null` and `apiKey: our-key`. Symmetric guard for the inverse direction. - ANTHROPIC_BASE_URL env points to proxy, config.baseUrl unset → proxy identity bundle (claude-cli UA, x-app, Bearer auth) applies. - ANTHROPIC_BASE_URL unset → SDK default api.anthropic.com path keeps native identity (predicate doesn't misclassify the SDK default as a proxy). - config.baseUrl wins over ANTHROPIC_BASE_URL — mirrors the SDK's own resolution order. - Existing 7 identity tests updated from `toBeUndefined()` to `toBeNull()` to match the new explicit-suppression contract.
This commit is contained in:
parent
f8a096e041
commit
ea4ce13dfd
2 changed files with 214 additions and 12 deletions
|
|
@ -130,7 +130,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).toContain('(external, cli)');
|
||||
expect(headers['x-app']).toBe('cli');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
|
||||
});
|
||||
|
||||
it('uses QwenCode identity + apiKey auth when baseURL is api.anthropic.com', async () => {
|
||||
|
|
@ -157,7 +157,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).not.toContain('claude-cli');
|
||||
expect(headers['x-app']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeNull();
|
||||
});
|
||||
|
||||
it('treats unset baseURL as Anthropic-native (SDK default targets api.anthropic.com)', async () => {
|
||||
|
|
@ -179,7 +179,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).toContain('QwenCode/1.2.3');
|
||||
expect(headers['x-app']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeNull();
|
||||
});
|
||||
|
||||
it('treats *.anthropic.com subdomains as Anthropic-native', async () => {
|
||||
|
|
@ -205,7 +205,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).toContain('QwenCode/1.2.3');
|
||||
expect(headers['x-app']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeNull();
|
||||
});
|
||||
|
||||
it('treats malformed baseURL as proxy (URL parse failure falls through to claude-cli identity)', async () => {
|
||||
|
|
@ -231,7 +231,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
|
||||
expect(headers['x-app']).toBe('cli');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
|
||||
});
|
||||
|
||||
it('pins DeepSeek anthropic-compatible baseURL onto the proxy auth/identity path', async () => {
|
||||
|
|
@ -261,7 +261,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
|
||||
expect(headers['x-app']).toBe('cli');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
|
||||
});
|
||||
|
||||
it('does not match spoofed anthropic.com.evil.com hostnames', async () => {
|
||||
|
|
@ -288,7 +288,174 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
|
||||
expect(headers['x-app']).toBe('cli');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
|
||||
});
|
||||
|
||||
// Regression coverage for #4020 review: the SDK destructures with
|
||||
// defaults (`apiKey = readEnv('ANTHROPIC_API_KEY') ?? null`), which only
|
||||
// fire for `undefined`. Spreading `{ authToken }` alone — without an
|
||||
// explicit `apiKey: null` — used to let the env back-fill `apiKey`, and
|
||||
// the SDK's auth resolver then preferred `apiKey` over `authToken`, so a
|
||||
// user with `ANTHROPIC_API_KEY=sk-ant-…` exported alongside an IdeaLab
|
||||
// proxy `baseUrl` shipped their real Anthropic key to the proxy as
|
||||
// `X-Api-Key`. These tests pin the explicit-null suppression on both
|
||||
// branches, plus the matching baseURL-env resolution.
|
||||
describe('env back-fill suppression and baseURL env resolution', () => {
|
||||
const ENV_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
];
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
beforeEach(() => {
|
||||
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
});
|
||||
|
||||
it('suppresses ANTHROPIC_API_KEY back-fill on the proxy branch (prevents credential leak)', async () => {
|
||||
// Scenario: user runs Claude Code in the same shell so
|
||||
// ANTHROPIC_API_KEY is exported with their real Anthropic key, and
|
||||
// separately configures qwen-code with an IdeaLab proxy + IdeaLab
|
||||
// token. Pre-fix, the SDK's destructuring default would back-fill
|
||||
// `apiKey` from the env, then the auth resolver would prefer it
|
||||
// over our `authToken` and ship `X-Api-Key: <real Anthropic key>`
|
||||
// to the third-party proxy.
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-secret-do-not-leak';
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
void new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-test',
|
||||
apiKey: 'idealab-token',
|
||||
baseUrl: 'https://idealab.example/anthropic',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: {},
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
// The constructor must receive an explicit `null` so the SDK
|
||||
// destructuring default for ANTHROPIC_API_KEY does NOT fire.
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBe(
|
||||
'idealab-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses ANTHROPIC_AUTH_TOKEN back-fill on the Anthropic-native branch', async () => {
|
||||
// Inverse of the leak: if the user has ANTHROPIC_AUTH_TOKEN set
|
||||
// (an Anthropic-supported alt) and routes to api.anthropic.com,
|
||||
// we should still ship our explicit `apiKey` rather than letting
|
||||
// the env back-fill `authToken` and risk the SDK picking the wrong
|
||||
// one if precedence flips in a future SDK version.
|
||||
process.env['ANTHROPIC_AUTH_TOKEN'] = 'env-bearer-token';
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
void new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'config-api-key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: {},
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBe(
|
||||
'config-api-key',
|
||||
);
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeNull();
|
||||
});
|
||||
|
||||
it('applies proxy identity when ANTHROPIC_BASE_URL env points to a proxy and config.baseUrl is unset', async () => {
|
||||
// Symmetric concern: pre-fix, `isAnthropicNativeBaseUrl` only read
|
||||
// `config.baseUrl`, so a user who set ANTHROPIC_BASE_URL only via
|
||||
// env (leaving qwen-code's baseUrl unset) had the SDK route to the
|
||||
// proxy while our predicate thought it was Anthropic-native — wrong
|
||||
// UA, wrong auth shape, and the cache-scope beta + scope:'global'
|
||||
// shipped to a proxy that likely doesn't recognize them.
|
||||
process.env['ANTHROPIC_BASE_URL'] = 'https://idealab.example/anthropic';
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
void new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-test',
|
||||
apiKey: 'idealab-token',
|
||||
// baseUrl intentionally omitted; SDK uses ANTHROPIC_BASE_URL env.
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: {},
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
|
||||
{}) as Record<string, string>;
|
||||
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
|
||||
expect(headers['x-app']).toBe('cli');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBe(
|
||||
'idealab-token',
|
||||
);
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps Anthropic-native identity when ANTHROPIC_BASE_URL is unset (SDK default applies)', async () => {
|
||||
// With no config.baseUrl and no env, the SDK defaults to
|
||||
// api.anthropic.com — our predicate must agree and ship the native
|
||||
// identity bundle (so the SDK default isn't silently misclassified
|
||||
// as a proxy).
|
||||
delete process.env['ANTHROPIC_BASE_URL'];
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
void new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'config-key',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: {},
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
|
||||
{}) as Record<string, string>;
|
||||
expect(headers['User-Agent']).toContain('QwenCode/1.2.3');
|
||||
expect(headers['x-app']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBe('config-key');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeNull();
|
||||
});
|
||||
|
||||
it('config.baseUrl wins over ANTHROPIC_BASE_URL when both are set', async () => {
|
||||
// Mirror the SDK's own resolution: explicit config beats env. A
|
||||
// user who deliberately points qwen-code at api.anthropic.com
|
||||
// shouldn't have a stray ANTHROPIC_BASE_URL silently flip them
|
||||
// onto the proxy path.
|
||||
process.env['ANTHROPIC_BASE_URL'] = 'https://idealab.example/anthropic';
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
void new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-opus-4-7',
|
||||
apiKey: 'config-key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: {},
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
|
||||
{}) as Record<string, string>;
|
||||
expect(headers['User-Agent']).toContain('QwenCode/1.2.3');
|
||||
expect(headers['x-app']).toBeUndefined();
|
||||
expect(anthropicState.constructorOptions?.['apiKey']).toBe('config-key');
|
||||
expect(anthropicState.constructorOptions?.['authToken']).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('merges customHeaders into defaultHeaders (does not replace defaults)', async () => {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,29 @@ function isDeepSeekAnthropicProvider(
|
|||
return model.includes('deepseek');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the baseURL the Anthropic SDK will actually use, mirroring the
|
||||
* SDK's own destructuring-default order: explicit config first, then
|
||||
* `ANTHROPIC_BASE_URL` env, then the SDK default. Returns the SDK default
|
||||
* literal when nothing is configured so callers can do hostname matching
|
||||
* without a special case for the empty path.
|
||||
*
|
||||
* The env read mirrors the SDK's `readEnv` helper (whitespace-trim,
|
||||
* treat empty as missing). Without this, a user who configures the proxy
|
||||
* purely through `ANTHROPIC_BASE_URL` while leaving qwen-code's `baseUrl`
|
||||
* unset would route the request to the proxy but ship the native identity
|
||||
* bundle, defeating the proxy gate.
|
||||
*/
|
||||
function resolveEffectiveBaseUrl(
|
||||
contentGeneratorConfig: ContentGeneratorConfig,
|
||||
): string {
|
||||
const fromConfig = contentGeneratorConfig.baseUrl;
|
||||
if (fromConfig) return fromConfig;
|
||||
const fromEnv = process.env['ANTHROPIC_BASE_URL']?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return 'https://api.anthropic.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the resolved baseURL is Anthropic's native API (or the SDK default
|
||||
* when no baseURL is set). Used to gate IdeaLab-style proxy workarounds —
|
||||
|
|
@ -93,10 +116,10 @@ function isDeepSeekAnthropicProvider(
|
|||
function isAnthropicNativeBaseUrl(
|
||||
contentGeneratorConfig: ContentGeneratorConfig,
|
||||
): boolean {
|
||||
const baseUrl = contentGeneratorConfig.baseUrl;
|
||||
if (!baseUrl) return true;
|
||||
try {
|
||||
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
||||
const hostname = new URL(
|
||||
resolveEffectiveBaseUrl(contentGeneratorConfig),
|
||||
).hostname.toLowerCase();
|
||||
return (
|
||||
hostname === 'api.anthropic.com' || hostname.endsWith('.anthropic.com')
|
||||
);
|
||||
|
|
@ -161,10 +184,22 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
// when targeting a non-Anthropic-native baseURL — direct
|
||||
// `api.anthropic.com` users keep the SDK-default `apiKey` (`x-api-key`)
|
||||
// path so they don't break against the Anthropic API itself.
|
||||
//
|
||||
// Pass `null` on the unused side rather than omitting it: the SDK
|
||||
// destructures with defaults (`apiKey = readEnv('ANTHROPIC_API_KEY') ?? null`,
|
||||
// same for `authToken`), and destructuring defaults fire ONLY for
|
||||
// `undefined`. Omitting the field would let `ANTHROPIC_API_KEY` /
|
||||
// `ANTHROPIC_AUTH_TOKEN` env back-fill it; the SDK's auth resolver
|
||||
// then prefers `apiKey` over `authToken`, so a user with
|
||||
// `ANTHROPIC_API_KEY=sk-ant-…` exported (common for anyone who also
|
||||
// runs Claude Code in the same shell) would ship their real Anthropic
|
||||
// key as `X-Api-Key` to the IdeaLab proxy — leaking the credential to
|
||||
// a third-party endpoint. Explicit `null` suppresses the back-fill
|
||||
// and forces the intended auth path.
|
||||
this.client = new Anthropic({
|
||||
...(useProxyIdentity
|
||||
? { authToken: contentGeneratorConfig.apiKey }
|
||||
: { apiKey: contentGeneratorConfig.apiKey }),
|
||||
? { authToken: contentGeneratorConfig.apiKey, apiKey: null }
|
||||
: { apiKey: contentGeneratorConfig.apiKey, authToken: null }),
|
||||
baseURL,
|
||||
timeout: contentGeneratorConfig.timeout || DEFAULT_TIMEOUT,
|
||||
maxRetries: contentGeneratorConfig.maxRetries,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue