mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix(kosong): isolate anthropic auth environment (#790)
* fix(kosong): isolate anthropic auth environment * fix(kosong): close remaining anthropic env fallbacks Pass explicit nulls into the Anthropic SDK for unused auth/base URL overrides, keep adapter-owned auth headers authoritative, and add regression coverage for Anthropic shell env leakage. * fix(kosong): block anthropic custom header env leakage * docs(kosong): explain anthropic env-isolation intent + migration Spell out that the SDK is used as a transport to arbitrary endpoints, so disabling its shell-env auto-discovery is the fix: the authToken/baseURL/header nulls are load-bearing, not redundant. Also document the behavior change (shell ANTHROPIC_* no longer read; use provider config) in the changeset. No logic change.
This commit is contained in:
parent
b45672cdaa
commit
d0d5821900
4 changed files with 134 additions and 18 deletions
10
.changeset/anthropic-env-auth.md
Normal file
10
.changeset/anthropic-env-auth.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
"@moonshot-ai/kosong": patch
|
||||
---
|
||||
|
||||
Isolate the Anthropic adapter from ambient shell credentials.
|
||||
|
||||
The adapter is used as a generic transport to *any* anthropic-compatible endpoint (`baseUrl` may point at a third-party gateway). The underlying Anthropic SDK, however, auto-discovers credentials from the shell environment by default. When the endpoint is not official this leaks: even with an explicit API key configured, the SDK would still read `ANTHROPIC_AUTH_TOKEN` from the environment and attach it as an `Authorization: Bearer` header — sending an out-of-band token (often injected by another tool, unbeknownst to the user) to the third-party `baseUrl`. The adapter now hard-disables every SDK environment auto-discovery channel (`authToken`, `baseURL`, custom headers) and uses only host-provided credentials.
|
||||
|
||||
**Behavior change:** the adapter no longer reads `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_CUSTOM_HEADERS` from the shell. Configure credentials through provider config (`apiKey` or `[provider.env]`) instead.
|
||||
|
|
@ -37,11 +37,7 @@ import type {
|
|||
ToolUseBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/messages/messages.js';
|
||||
|
||||
import {
|
||||
mergeRequestHeaders,
|
||||
requireProviderApiKey,
|
||||
resolveAuthBackedClient,
|
||||
} from './request-auth';
|
||||
import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth';
|
||||
import {
|
||||
normalizeToolCallIdsForProvider,
|
||||
sanitizeToolCallId,
|
||||
|
|
@ -862,7 +858,7 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
private _metadata: Record<string, string> | undefined;
|
||||
private _apiKey: string | undefined;
|
||||
private _baseUrl: string | undefined;
|
||||
private _defaultHeaders: Record<string, string> | undefined;
|
||||
private _defaultHeaders: Record<string, string | null> | undefined;
|
||||
private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined;
|
||||
private _adaptiveThinking: boolean | undefined;
|
||||
private _explicitMaxTokens: boolean;
|
||||
|
|
@ -872,8 +868,8 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
this._stream = options.stream ?? true;
|
||||
this._metadata = options.metadata;
|
||||
this._adaptiveThinking = options.adaptiveThinking;
|
||||
const apiKey = options.apiKey ?? process.env['ANTHROPIC_API_KEY'];
|
||||
this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey;
|
||||
this._apiKey =
|
||||
options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey;
|
||||
this._baseUrl = options.baseUrl;
|
||||
this._defaultHeaders = options.defaultHeaders;
|
||||
this._clientFactory = options.clientFactory;
|
||||
|
|
@ -1073,15 +1069,62 @@ export class AnthropicChatProvider implements ChatProvider {
|
|||
return resolveAuthBackedClient(
|
||||
{ cachedClient: this._client, clientFactory: this._clientFactory },
|
||||
auth,
|
||||
(a) => this._buildClient(requireProviderApiKey('AnthropicChatProvider', a, this._apiKey)),
|
||||
(a) => this._buildClient(this._requireApiKey(a)),
|
||||
);
|
||||
}
|
||||
|
||||
private _requireApiKey(auth: ProviderRequestAuth | undefined): string {
|
||||
const apiKey = auth?.apiKey ?? this._apiKey;
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new ChatProviderError(
|
||||
'AnthropicChatProvider: apiKey is required. Provide it via constructor options, options.auth.apiKey on each request, or an OAuth login. The Anthropic adapter does not read shell API-key environment variables.',
|
||||
);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
private _anthropicCustomHeaderEnvNames(): string[] {
|
||||
const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS'];
|
||||
if (customHeaders === undefined || customHeaders.length === 0) return [];
|
||||
|
||||
const names: string[] = [];
|
||||
for (const line of customHeaders.split('\n')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
const name = line.slice(0, colonIndex).trim().toLowerCase();
|
||||
if (name.length > 0) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private _buildDefaultHeaders(apiKey: string): Record<string, string | null> {
|
||||
const defaultHeaders: Record<string, string | null> = { authorization: null };
|
||||
for (const name of this._anthropicCustomHeaderEnvNames()) {
|
||||
defaultHeaders[name] = null;
|
||||
}
|
||||
for (const [name, value] of Object.entries(this._defaultHeaders ?? {})) {
|
||||
defaultHeaders[name.toLowerCase()] = value;
|
||||
}
|
||||
defaultHeaders['x-api-key'] = apiKey;
|
||||
return defaultHeaders;
|
||||
}
|
||||
|
||||
// We use the Anthropic SDK purely as a transport to arbitrary
|
||||
// anthropic-compatible endpoints (`baseUrl` may point anywhere). Left to its
|
||||
// defaults the SDK auto-discovers credentials from the shell environment
|
||||
// (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS), which
|
||||
// would leak an out-of-band bearer/headers to a third-party endpoint even when
|
||||
// an explicit apiKey is set. So we hard-disable every auto-discovery channel.
|
||||
// These `null`s — and the nulled headers in _buildDefaultHeaders — are NOT
|
||||
// redundant: removing them reintroduces credential leakage. Regression cover:
|
||||
// test/e2e/anthropic-adapter.test.ts.
|
||||
private _buildClient(apiKey: string): Anthropic {
|
||||
return new Anthropic({
|
||||
apiKey,
|
||||
baseURL: this._baseUrl,
|
||||
defaultHeaders: this._defaultHeaders,
|
||||
authToken: null,
|
||||
baseURL: this._baseUrl ?? null,
|
||||
defaultHeaders: this._buildDefaultHeaders(apiKey),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,49 @@ const B64_PNG =
|
|||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA' +
|
||||
'DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
describe('AnthropicChatProvider', () => {
|
||||
it('does not read ANTHROPIC_API_KEY from process.env inside the adapter', () => {
|
||||
const previousApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
process.env['ANTHROPIC_API_KEY'] = 'env-key';
|
||||
|
||||
try {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'k25',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(Reflect.get(provider, '_apiKey')).toBeUndefined();
|
||||
expect(Reflect.get(provider, '_client')).toBeUndefined();
|
||||
} finally {
|
||||
if (previousApiKey === undefined) {
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
} else {
|
||||
process.env['ANTHROPIC_API_KEY'] = previousApiKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not read ANTHROPIC_BASE_URL from process.env inside the adapter', () => {
|
||||
const previousBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||
process.env['ANTHROPIC_BASE_URL'] = 'http://127.0.0.1:1';
|
||||
|
||||
try {
|
||||
const provider = new AnthropicChatProvider({
|
||||
model: 'k25',
|
||||
apiKey: 'test-key',
|
||||
stream: false,
|
||||
});
|
||||
const client = Reflect.get(provider, '_client') as { baseURL?: string } | undefined;
|
||||
|
||||
expect(client?.baseURL).toBe('https://api.anthropic.com');
|
||||
} finally {
|
||||
if (previousBaseUrl === undefined) {
|
||||
delete process.env['ANTHROPIC_BASE_URL'];
|
||||
} else {
|
||||
process.env['ANTHROPIC_BASE_URL'] = previousBaseUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('message conversion', () => {
|
||||
it('simple user message with system prompt', async () => {
|
||||
const provider = createProvider();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { Message, StreamedMessagePart, ToolCall } from '#/message';
|
|||
import { AnthropicChatProvider } from '#/providers/anthropic';
|
||||
import type { Tool } from '#/tool';
|
||||
import type { TokenUsage } from '#/usage';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createFakeProviderHarness } from './fake-provider-harness';
|
||||
|
|
@ -21,10 +20,15 @@ async function collectParts(
|
|||
return parts;
|
||||
}
|
||||
|
||||
function makeAnthropicProvider(): AnthropicChatProvider {
|
||||
function makeAnthropicProvider(
|
||||
baseUrl?: string,
|
||||
defaultHeaders?: Record<string, string>,
|
||||
): AnthropicChatProvider {
|
||||
return new AnthropicChatProvider({
|
||||
model: 'k25',
|
||||
apiKey: 'test-key',
|
||||
baseUrl,
|
||||
defaultHeaders,
|
||||
defaultMaxTokens: 1024,
|
||||
stream: true,
|
||||
});
|
||||
|
|
@ -58,8 +62,15 @@ const MUL_TOOL: Tool = {
|
|||
|
||||
describe('e2e: Anthropic adapter bridge', () => {
|
||||
it('sends the adapter request body and parses streamed text, tool use, and usage', async () => {
|
||||
const previousAuthToken = process.env['ANTHROPIC_AUTH_TOKEN'];
|
||||
const previousCustomHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS'];
|
||||
const harness = await createFakeProviderHarness();
|
||||
|
||||
try {
|
||||
process.env['ANTHROPIC_AUTH_TOKEN'] = 'env-auth-token';
|
||||
process.env['ANTHROPIC_CUSTOM_HEADERS'] =
|
||||
'Authorization: Bearer env-token\nX-Api-Key: env-key\nX-Leak: shell';
|
||||
|
||||
harness.route('POST', '/v1/messages', async (request, reply) => {
|
||||
const body = request.bodyJson as Record<string, unknown>;
|
||||
expect(request.pathname).toBe('/v1/messages');
|
||||
|
|
@ -168,11 +179,7 @@ describe('e2e: Anthropic adapter bridge', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const provider = makeAnthropicProvider();
|
||||
(provider as any)._client = new Anthropic({
|
||||
apiKey: 'test-key',
|
||||
baseURL: harness.baseUrl,
|
||||
});
|
||||
const provider = makeAnthropicProvider(harness.baseUrl, { 'X-Configured': 'yes' });
|
||||
|
||||
const history: Message[] = [
|
||||
{
|
||||
|
|
@ -209,7 +216,20 @@ describe('e2e: Anthropic adapter bridge', () => {
|
|||
} satisfies TokenUsage);
|
||||
|
||||
expect(harness.requests).toHaveLength(1);
|
||||
expect(harness.requests[0]!.headers['authorization']).toBeUndefined();
|
||||
expect(harness.requests[0]!.headers['x-leak']).toBeUndefined();
|
||||
expect(harness.requests[0]!.headers['x-configured']).toBe('yes');
|
||||
} finally {
|
||||
if (previousAuthToken === undefined) {
|
||||
delete process.env['ANTHROPIC_AUTH_TOKEN'];
|
||||
} else {
|
||||
process.env['ANTHROPIC_AUTH_TOKEN'] = previousAuthToken;
|
||||
}
|
||||
if (previousCustomHeaders === undefined) {
|
||||
delete process.env['ANTHROPIC_CUSTOM_HEADERS'];
|
||||
} else {
|
||||
process.env['ANTHROPIC_CUSTOM_HEADERS'] = previousCustomHeaders;
|
||||
}
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue