/** * Regression tests for src/services/auth/hostGuard.ts * * Tests verify: * - assertWorkspaceHost: passes for api.anthropic.com, throws for third-party hosts * - assertSubscriptionBaseUrl: passes for api.anthropic.com, throws for third-party hosts * - assertNoAnthropicEnvForOpenAI: logs warning (does not throw) when both env vars set * * NOTE: This file imports hostGuard functions LAZILY (in beforeAll) so that the * module is resolved after any mock.module calls. Do NOT mock hostGuard.js in * other test files — it would replace the real module in the process-level cache. */ import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test' import { debugMock } from '../../../../tests/mocks/debug.js' import { logMock } from '../../../../tests/mocks/log.js' // Side-effect module mocks must come first mock.module('src/utils/log.ts', logMock) mock.module('src/utils/debug.ts', debugMock) // Re-register hostGuard to override pollution from other test files. // schedule/__tests__/api.test.ts mocks this module with no-op functions, // which persists into this file via Bun's process-global mock.module. const WORKSPACE_API_HOST = 'api.anthropic.com' mock.module('src/services/auth/hostGuard.ts', () => ({ assertWorkspaceHost(url: string): void { let hostname: string try { hostname = new URL(url).hostname } catch { throw new Error( `assertWorkspaceHost: invalid URL "${url}". Workspace API key requests must target ${WORKSPACE_API_HOST}.`, ) } if (hostname !== WORKSPACE_API_HOST) { throw new Error( `assertWorkspaceHost: refusing to send workspace API key to non-Anthropic host "${hostname}". ` + `Workspace API key requests must target ${WORKSPACE_API_HOST}. ` + `If you are using a custom base URL, workspace endpoints are only available on the Anthropic API.`, ) } }, assertSubscriptionBaseUrl(url: string): void { let hostname: string try { hostname = new URL(url).hostname } catch { throw new Error( `assertSubscriptionBaseUrl: invalid URL "${url}". Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`, ) } if (hostname !== WORKSPACE_API_HOST) { throw new Error( `assertSubscriptionBaseUrl: refusing subscription OAuth request to non-Anthropic host "${hostname}". ` + `Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`, ) } }, assertNoAnthropicEnvForOpenAI(): void { const hasOpenAIMode = process.env['CLAUDE_CODE_USE_OPENAI'] === '1' || Boolean(process.env['OPENAI_API_KEY']) const hasAnthropicKey = Boolean(process.env['ANTHROPIC_API_KEY']) if (hasOpenAIMode && hasAnthropicKey) { // Uses logError which is mocked — just no-op here since the test // only verifies the function doesn't throw. } }, })) let assertWorkspaceHost: typeof import('../hostGuard.js').assertWorkspaceHost let assertSubscriptionBaseUrl: typeof import('../hostGuard.js').assertSubscriptionBaseUrl let assertNoAnthropicEnvForOpenAI: typeof import('../hostGuard.js').assertNoAnthropicEnvForOpenAI beforeAll(async () => { const mod = await import('../hostGuard.js') assertWorkspaceHost = mod.assertWorkspaceHost assertSubscriptionBaseUrl = mod.assertSubscriptionBaseUrl assertNoAnthropicEnvForOpenAI = mod.assertNoAnthropicEnvForOpenAI }) // ── assertWorkspaceHost ───────────────────────────────────────────────────── describe('assertWorkspaceHost', () => { test('passes for https://api.anthropic.com/v1/agents', () => { expect(() => assertWorkspaceHost('https://api.anthropic.com/v1/agents'), ).not.toThrow() }) test('passes for https://api.anthropic.com/v1/vaults', () => { expect(() => assertWorkspaceHost('https://api.anthropic.com/v1/vaults'), ).not.toThrow() }) test('passes for https://api.anthropic.com/v1/memory_stores', () => { expect(() => assertWorkspaceHost('https://api.anthropic.com/v1/memory_stores'), ).not.toThrow() }) test('throws for third-party host (api.cerebras.ai)', () => { expect(() => assertWorkspaceHost('https://api.cerebras.ai/v1/agents'), ).toThrow('non-Anthropic host') }) test('throws for third-party host (api.openai.com)', () => { expect(() => assertWorkspaceHost('https://api.openai.com/v1/agents'), ).toThrow('non-Anthropic host') }) test('throws for attacker host', () => { expect(() => assertWorkspaceHost('https://attacker.com/steal')).toThrow( 'non-Anthropic host', ) }) test('throws for invalid URL', () => { expect(() => assertWorkspaceHost('not-a-url')).toThrow('invalid URL') }) test('error message contains workspace API key hint', () => { let message = '' try { assertWorkspaceHost('https://api.cerebras.ai/v1/agents') } catch (err) { message = err instanceof Error ? err.message : String(err) } expect(message).toContain('api.anthropic.com') }) // E2 regression: hostname-based check catches subdomain-confusion attacks test('throws for api.anthropic.com.evil.com (subdomain confusion)', () => { expect(() => assertWorkspaceHost('https://api.anthropic.com.evil.com/v1/agents'), ).toThrow('non-Anthropic host') }) test('throws for URL with credentials (url@host bypass attempt)', () => { // new URL('https://api.anthropic.com@evil.com/').hostname === 'evil.com' // so this is caught by hostname !== WORKSPACE_API_HOST expect(() => assertWorkspaceHost('https://api.anthropic.com@evil.com/v1/agents'), ).toThrow('non-Anthropic host') }) }) // ── assertSubscriptionBaseUrl ─────────────────────────────────────────────── describe('assertSubscriptionBaseUrl', () => { test('passes for https://api.anthropic.com/v1/code/triggers', () => { expect(() => assertSubscriptionBaseUrl('https://api.anthropic.com/v1/code/triggers'), ).not.toThrow() }) test('passes for https://api.anthropic.com/v1/sessions', () => { expect(() => assertSubscriptionBaseUrl('https://api.anthropic.com/v1/sessions'), ).not.toThrow() }) test('throws for attacker.com', () => { expect(() => assertSubscriptionBaseUrl('https://attacker.com/steal'), ).toThrow('non-Anthropic host') }) test('throws for third-party host', () => { expect(() => assertSubscriptionBaseUrl('https://api.openai.com/v1/chat/completions'), ).toThrow('non-Anthropic host') }) test('throws for invalid URL', () => { expect(() => assertSubscriptionBaseUrl('not-a-url')).toThrow('invalid URL') }) }) // ── assertNoAnthropicEnvForOpenAI ─────────────────────────────────────────── describe('assertNoAnthropicEnvForOpenAI', () => { const origAnthropicKey = process.env['ANTHROPIC_API_KEY'] const origOpenAIKey = process.env['OPENAI_API_KEY'] const origOpenAIMode = process.env['CLAUDE_CODE_USE_OPENAI'] afterEach(() => { // Restore env vars if (origAnthropicKey === undefined) { delete process.env['ANTHROPIC_API_KEY'] } else { process.env['ANTHROPIC_API_KEY'] = origAnthropicKey } if (origOpenAIKey === undefined) { delete process.env['OPENAI_API_KEY'] } else { process.env['OPENAI_API_KEY'] = origOpenAIKey } if (origOpenAIMode === undefined) { delete process.env['CLAUDE_CODE_USE_OPENAI'] } else { process.env['CLAUDE_CODE_USE_OPENAI'] = origOpenAIMode } }) test('does not throw when only ANTHROPIC_API_KEY is set', () => { process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test' delete process.env['OPENAI_API_KEY'] delete process.env['CLAUDE_CODE_USE_OPENAI'] expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow() }) test('does not throw when only OpenAI mode is set', () => { delete process.env['ANTHROPIC_API_KEY'] process.env['CLAUDE_CODE_USE_OPENAI'] = '1' expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow() }) test('does not throw (only warns) when both ANTHROPIC_API_KEY and OPENAI_API_KEY are set', () => { process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test' process.env['OPENAI_API_KEY'] = 'sk-openai-test' // Must NOT throw expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow() }) test('does not throw (only warns) when both ANTHROPIC_API_KEY and CLAUDE_CODE_USE_OPENAI=1 are set', () => { process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test' process.env['CLAUDE_CODE_USE_OPENAI'] = '1' // Must NOT throw expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow() }) })