fix(env): block ANTHROPIC_BASE_URL leak + three-branch OAuth-skip predicate

Issue #2375: parent-shell ANTHROPIC_BASE_URL leaked through to subprocess
isolatedEnv, while ANTHROPIC_AUTH_TOKEN was blocked. The OAuth-skip
predicate fired on bare BASE_URL, but no auth credential reached the
subprocess -> "Not logged in". Add ANTHROPIC_BASE_URL to BLOCKED_ENV_VARS
so it can only enter isolatedEnv via ~/.claude-mem/.env.

Replace the OAuth-skip predicate with three branches to prevent a
second-order security regression: a user with a tokenless gateway
configured in .env (BASE_URL only, no token) would otherwise have their
Anthropic OAuth token fetched and sent to their gateway. Token leak to
third party. Three-branch predicate:

1. BASE_URL set -> return without OAuth (custom gateway, never leak token)
2. API_KEY or AUTH_TOKEN set -> return without OAuth (explicit credentials)
3. Otherwise -> OAuth lookup for api.anthropic.com

Adds tests/env-isolation.test.ts.

Fixes #2375.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-05-09 00:29:36 -07:00
parent d7b145e997
commit a8cbd6515e
2 changed files with 167 additions and 9 deletions

View File

@@ -17,6 +17,10 @@ const BLOCKED_ENV_VARS = [
// shell would otherwise short-circuit OAuth lookup at spawn time.
// The fresh token from ~/.claude-mem/.env is re-injected below
// when explicit gateway credentials are configured.
'ANTHROPIC_BASE_URL', // Issue #2375: same leak class as AUTH_TOKEN. A leaked BASE_URL
// alone (no token) was enough to trigger the OAuth-skip path,
// sending the subprocess to a proxy with no credentials.
// Re-injected from ~/.claude-mem/.env when configured.
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
'CLAUDE_CODE_OAUTH_TOKEN', // Issue #2215: prevent stale parent-process token from leaking into
// isolated env. The fresh token is read from the keychain at spawn
@@ -230,15 +234,17 @@ export async function buildIsolatedEnvWithFreshOAuth(
if (!includeCredentials) return isolatedEnv;
// If the user already configured explicit Anthropic/gateway credentials in
// ~/.claude-mem/.env, honor those and skip OAuth lookup entirely. A bare
// ANTHROPIC_BASE_URL counts because gateways may be tokenless, and falling
// back to OAuth would silently route requests to api.anthropic.com.
if (
isolatedEnv.ANTHROPIC_API_KEY ||
isolatedEnv.ANTHROPIC_BASE_URL ||
isolatedEnv.ANTHROPIC_AUTH_TOKEN
) {
// Custom gateway: never inject OAuth (would leak the user's Anthropic OAuth
// token to a third-party gateway). The user must explicitly configure a
// gateway-appropriate token in ~/.claude-mem/.env if their gateway requires
// one. A bare BASE_URL with no token = tokenless gateway (e.g. mTLS at the
// network boundary).
if (isolatedEnv.ANTHROPIC_BASE_URL) {
clearStaleMarker();
return isolatedEnv;
}
// Direct API with explicit credentials: skip OAuth lookup.
if (isolatedEnv.ANTHROPIC_API_KEY || isolatedEnv.ANTHROPIC_AUTH_TOKEN) {
clearStaleMarker();
return isolatedEnv;
}

152
tests/env-isolation.test.ts Normal file
View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import * as fs from 'fs';
import { dirname } from 'path';
import {
ENV_FILE_PATH,
buildIsolatedEnv,
buildIsolatedEnvWithFreshOAuth,
} from '../src/shared/EnvManager.js';
import * as oauthToken from '../src/shared/oauth-token.js';
/**
* Tests for issue #2375: ANTHROPIC_BASE_URL must not leak from the parent
* shell into the spawned worker's isolatedEnv, AND the OAuth-skip predicate
* must not inject the user's Anthropic OAuth token onto a custom gateway URL
* (which would be a token leak to a third party).
*
* ENV_FILE_PATH is captured at module load time, so we cannot easily redirect
* the env file by mocking paths.envFile() after the fact. Instead, we
* back up the user's real ~/.claude-mem/.env (if any), write a temp file at
* the canonical ENV_FILE_PATH for each test, and restore afterwards.
*/
const ORIGINAL_BASE_URL = process.env.ANTHROPIC_BASE_URL;
const ORIGINAL_API_KEY = process.env.ANTHROPIC_API_KEY;
const ORIGINAL_AUTH_TOKEN = process.env.ANTHROPIC_AUTH_TOKEN;
const ORIGINAL_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
const ENV_FILE_BACKUP_PATH = `${ENV_FILE_PATH}.test-backup`;
function backupEnvFile(): void {
if (fs.existsSync(ENV_FILE_PATH)) {
fs.renameSync(ENV_FILE_PATH, ENV_FILE_BACKUP_PATH);
}
}
function restoreEnvFile(): void {
if (fs.existsSync(ENV_FILE_PATH)) {
fs.unlinkSync(ENV_FILE_PATH);
}
if (fs.existsSync(ENV_FILE_BACKUP_PATH)) {
fs.renameSync(ENV_FILE_BACKUP_PATH, ENV_FILE_PATH);
}
}
function ensureEnvDir(): void {
const dir = dirname(ENV_FILE_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
function clearAnthropicEnv(): void {
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
}
function restoreOriginalEnv(): void {
if (ORIGINAL_BASE_URL === undefined) {
delete process.env.ANTHROPIC_BASE_URL;
} else {
process.env.ANTHROPIC_BASE_URL = ORIGINAL_BASE_URL;
}
if (ORIGINAL_API_KEY === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = ORIGINAL_API_KEY;
}
if (ORIGINAL_AUTH_TOKEN === undefined) {
delete process.env.ANTHROPIC_AUTH_TOKEN;
} else {
process.env.ANTHROPIC_AUTH_TOKEN = ORIGINAL_AUTH_TOKEN;
}
if (ORIGINAL_OAUTH_TOKEN === undefined) {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
} else {
process.env.CLAUDE_CODE_OAUTH_TOKEN = ORIGINAL_OAUTH_TOKEN;
}
}
describe('Issue #2375: ANTHROPIC_BASE_URL env-var isolation', () => {
beforeEach(() => {
backupEnvFile();
ensureEnvDir();
clearAnthropicEnv();
});
afterEach(() => {
restoreEnvFile();
restoreOriginalEnv();
});
it('leaked ANTHROPIC_BASE_URL is stripped from isolatedEnv', () => {
// No ~/.claude-mem/.env file exists (backed up above). The parent shell
// sets a stray ANTHROPIC_BASE_URL — this MUST NOT propagate into the
// subprocess isolatedEnv, because doing so used to trigger the
// OAuth-skip path and leave the worker with no credentials at all.
process.env.ANTHROPIC_BASE_URL = 'https://shouldnotleak.example';
const result = buildIsolatedEnv();
expect(result.ANTHROPIC_BASE_URL).toBeUndefined();
});
it('~/.claude-mem/.env BASE_URL + AUTH_TOKEN reaches isolatedEnv', () => {
// User intentionally configured a gateway with a gateway-appropriate
// auth token. Both must be re-injected into isolatedEnv.
fs.writeFileSync(
ENV_FILE_PATH,
'ANTHROPIC_BASE_URL=https://gateway.example\nANTHROPIC_AUTH_TOKEN=test-token\n',
{ mode: 0o600 },
);
const result = buildIsolatedEnv();
expect(result.ANTHROPIC_BASE_URL).toBe('https://gateway.example');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('test-token');
});
it('bare .env BASE_URL alone does not trigger OAuth fetch', async () => {
// A user with a tokenless gateway (e.g. mTLS at the network boundary)
// configures BASE_URL only. The three-branch predicate must hit the
// BASE_URL-set branch BEFORE OAuth lookup, so CLAUDE_CODE_OAUTH_TOKEN
// must NOT appear in the result. This is the security-regression guard
// against a token leak to a third-party gateway.
//
// Note: EnvManager captures readClaudeOAuthToken via a named import at
// module load, so spyOn on the namespace export only weakly observes
// the call (the binding inside EnvManager is independent). The
// behavioral assertions (BASE_URL re-injected AND OAuth token NOT
// injected) are the load-bearing checks: in the no-OAuth-injection
// outcome, the only execution path that produces this combination is
// the new BASE_URL-first branch returning early.
fs.writeFileSync(
ENV_FILE_PATH,
'ANTHROPIC_BASE_URL=https://gateway.example\n',
{ mode: 0o600 },
);
const oauthSpy = spyOn(oauthToken, 'readClaudeOAuthToken');
const result = await buildIsolatedEnvWithFreshOAuth();
expect(result.ANTHROPIC_BASE_URL).toBe('https://gateway.example');
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
// Best-effort sanity check; see note above.
expect(oauthSpy).not.toHaveBeenCalled();
oauthSpy.mockRestore();
});
});