security: fix 5 findings from issue #42 — env leak, ant gate, depth DoS, URL parse, CA cert

Finding 1 [CRITICAL] — sessionRunner leaks full process.env to child
Extract buildChildEnv() with an explicit allowlist of safe OS/runtime vars.
Child process no longer inherits ANTHROPIC_API_KEY, OPENAI_API_KEY, DB
credentials, or any other secret present in the parent shell environment.
Only CLAUDE_CODE_* bridge vars, PATH, HOME, and standard OS env are passed.

Finding 2 [HIGH] — USER_TYPE=ant activatable by external users
Add isAntEmployee() -> false constant in src/utils/buildConfig.ts.
Replace all three direct process.env.USER_TYPE === 'ant' checks in
setup.ts and onChangeAppState.ts so no external user can activate
Anthropic-internal code paths (commit attribution, system prompt clearing,
dangerously-skip-permissions bypass) by setting USER_TYPE in their shell.

Finding 3 [HIGH] — memoryScan.ts unlimited directory walk
Add MAX_DEPTH=3 guard on readdir({ recursive: true }) results.
Deep or symlink-looped memory directories no longer cause an unbounded
blocking walk before the MAX_MEMORY_FILES cap takes effect.

Finding 5 [HIGH] — buildSdkUrl uses string.includes for protocol detection
Replace apiBaseUrl.includes('localhost') with new URL(apiBaseUrl).hostname
comparison so a remote URL containing 'localhost' in its path no longer
incorrectly gets ws:// (unencrypted) instead of wss://.

Finding 6 [HIGH] — upstream proxy writes unvalidated CA cert to disk
Add isValidPemContent() validation before writeFile in the CA cert download
path. A compromised proxy sending non-PEM data (HTML, JSON, scripts) is now
rejected before it can be appended to the system CA bundle.

Each fix is covered by new unit tests (25 tests across 5 new test files).
All 52 tests pass. Build verified clean on v0.1.7.

Fixes #42

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gnanam1990
2026-04-02 21:04:10 +05:30
parent 3353101e83
commit 942d09ca9c
12 changed files with 363 additions and 24 deletions

View File

@@ -0,0 +1,85 @@
import { expect, test } from 'bun:test'
import { buildChildEnv } from './sessionRunner.ts'
// Finding #42-1: sessionRunner spreads the full parent process.env into the
// child process environment, leaking API keys, DB credentials, proxy secrets.
// Only CLAUDE_CODE_OAUTH_TOKEN was stripped. Fix: explicit allowlist.
const baseOpts = {
accessToken: 'test-access-token',
useCcrV2: false as const,
}
test('buildChildEnv does not leak ANTHROPIC_API_KEY to child', () => {
const parentEnv = {
PATH: '/usr/bin',
HOME: '/home/user',
ANTHROPIC_API_KEY: 'sk-ant-secret-key',
CLAUDE_CODE_SESSION_ACCESS_TOKEN: 'will-be-overwritten',
}
const env = buildChildEnv(parentEnv, baseOpts)
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
})
test('buildChildEnv does not leak OPENAI_API_KEY to child', () => {
const parentEnv = {
PATH: '/usr/bin',
HOME: '/home/user',
OPENAI_API_KEY: 'sk-openai-secret',
}
const env = buildChildEnv(parentEnv, baseOpts)
expect(env.OPENAI_API_KEY).toBeUndefined()
})
test('buildChildEnv does not leak arbitrary secrets to child', () => {
const parentEnv = {
PATH: '/usr/bin',
HOME: '/home/user',
DB_PASSWORD: 'super-secret',
AWS_SECRET_ACCESS_KEY: 'aws-secret',
GITHUB_TOKEN: 'ghp_token',
}
const env = buildChildEnv(parentEnv, baseOpts)
expect(env.DB_PASSWORD).toBeUndefined()
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined()
expect(env.GITHUB_TOKEN).toBeUndefined()
})
test('buildChildEnv includes PATH and HOME from parent', () => {
const parentEnv = {
PATH: '/usr/bin:/usr/local/bin',
HOME: '/home/user',
ANTHROPIC_API_KEY: 'sk-secret',
}
const env = buildChildEnv(parentEnv, baseOpts)
expect(env.PATH).toBe('/usr/bin:/usr/local/bin')
expect(env.HOME).toBe('/home/user')
})
test('buildChildEnv sets CLAUDE_CODE_SESSION_ACCESS_TOKEN from opts', () => {
const env = buildChildEnv({ PATH: '/usr/bin' }, { ...baseOpts, accessToken: 'my-token' })
expect(env.CLAUDE_CODE_SESSION_ACCESS_TOKEN).toBe('my-token')
})
test('buildChildEnv sets CLAUDE_CODE_ENVIRONMENT_KIND to bridge', () => {
const env = buildChildEnv({ PATH: '/usr/bin' }, baseOpts)
expect(env.CLAUDE_CODE_ENVIRONMENT_KIND).toBe('bridge')
})
test('buildChildEnv does not pass CLAUDE_CODE_OAUTH_TOKEN to child', () => {
const parentEnv = {
PATH: '/usr/bin',
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-to-strip',
}
const env = buildChildEnv(parentEnv, baseOpts)
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
})
test('buildChildEnv sets CCR v2 vars when useCcrV2 is true', () => {
const env = buildChildEnv(
{ PATH: '/usr/bin' },
{ accessToken: 'tok', useCcrV2: true, workerEpoch: 42 },
)
expect(env.CLAUDE_CODE_USE_CCR_V2).toBe('1')
expect(env.CLAUDE_CODE_WORKER_EPOCH).toBe('42')
})

View File

@@ -16,6 +16,69 @@ import type {
const MAX_ACTIVITIES = 10
const MAX_STDERR_LINES = 10
/**
* Safe OS and runtime variables that the child process needs to function.
* Everything else (API keys, DB passwords, proxy secrets, etc.) must not
* be inherited — the child authenticates via CLAUDE_CODE_SESSION_ACCESS_TOKEN.
*/
const CHILD_ENV_ALLOWLIST = new Set([
// System / shell
'PATH', 'HOME', 'USERPROFILE', 'HOMEPATH', 'HOMEDRIVE',
'USERNAME', 'USER', 'LOGNAME',
'TEMP', 'TMP', 'TMPDIR',
'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC', 'WINDIR',
'LANG', 'LC_ALL', 'LC_CTYPE',
// Node.js runtime
'NODE_OPTIONS', 'NODE_PATH', 'NODE_ENV',
// OpenClaude session / bridge (non-secret)
'CLAUDE_CODE_ENVIRONMENT_KIND',
'CLAUDE_CODE_FORCE_SANDBOX',
'CLAUDE_CODE_BUBBLEWRAP',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_COORDINATOR_MODE',
'CLAUDE_CODE_PERMISSIONS_VERSION',
'CLAUDE_CODE_PERMISSIONS_SETTING',
// Display / terminal
'TERM', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR',
])
type BuildChildEnvOpts = {
accessToken: string
useCcrV2: boolean
workerEpoch?: number
sandbox?: boolean
}
/**
* Build the environment for the child CC process from an explicit allowlist.
* This prevents the parent's API keys and credentials from leaking to the child.
*/
export function buildChildEnv(
parentEnv: NodeJS.ProcessEnv,
opts: BuildChildEnvOpts,
): NodeJS.ProcessEnv {
// Start from allowlisted parent vars only
const env: NodeJS.ProcessEnv = {}
for (const key of Object.keys(parentEnv)) {
if (CHILD_ENV_ALLOWLIST.has(key)) {
env[key] = parentEnv[key]
}
}
// Bridge-required overrides
env.CLAUDE_CODE_OAUTH_TOKEN = undefined // explicitly strip
env.CLAUDE_CODE_ENVIRONMENT_KIND = 'bridge'
if (opts.sandbox) env.CLAUDE_CODE_FORCE_SANDBOX = '1'
env.CLAUDE_CODE_SESSION_ACCESS_TOKEN = opts.accessToken
env.CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 = '1'
if (opts.useCcrV2) {
env.CLAUDE_CODE_USE_CCR_V2 = '1'
env.CLAUDE_CODE_WORKER_EPOCH = String(opts.workerEpoch)
}
return env
}
/**
* Sanitize a session ID for use in file names.
* Strips any characters that could cause path traversal (e.g. `../`, `/`)
@@ -303,24 +366,12 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
: []),
]
const env: NodeJS.ProcessEnv = {
...deps.env,
// Strip the bridge's OAuth token so the child CC process uses
// the session access token for inference instead.
CLAUDE_CODE_OAUTH_TOKEN: undefined,
CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',
...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }),
CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken,
// v1: HybridTransport (WS reads + POST writes) to Session-Ingress.
// Harmless in v2 mode — transportUtils checks CLAUDE_CODE_USE_CCR_V2 first.
CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2: '1',
// v2: SSETransport + CCRClient to CCR's /v1/code/sessions/* endpoints.
// Same env vars environment-manager sets in the container path.
...(opts.useCcrV2 && {
CLAUDE_CODE_USE_CCR_V2: '1',
CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch),
}),
}
const env = buildChildEnv(deps.env, {
accessToken: opts.accessToken,
useCcrV2: opts.useCcrV2,
workerEpoch: opts.workerEpoch,
sandbox: deps.sandbox,
})
deps.onDebug(
`[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`,

View File

@@ -0,0 +1,36 @@
import { expect, test } from 'bun:test'
import { buildSdkUrl } from './workSecret.ts'
// Finding #42-5: buildSdkUrl uses string.includes() on the full URL,
// so a remote URL containing "localhost" in its path gets ws:// (unencrypted).
test('buildSdkUrl uses wss for remote URL that contains localhost in path', () => {
const url = buildSdkUrl('https://remote.example.com/proxy/localhost/api', 'sess-1')
expect(url).toContain('wss://')
expect(url).not.toContain('ws://')
})
test('buildSdkUrl uses ws for actual localhost hostname', () => {
const url = buildSdkUrl('http://localhost:8080', 'sess-1')
expect(url).toContain('ws://')
})
test('buildSdkUrl uses ws for 127.0.0.1 hostname', () => {
const url = buildSdkUrl('http://127.0.0.1:3000', 'sess-1')
expect(url).toContain('ws://')
})
test('buildSdkUrl uses wss for regular remote hostname', () => {
const url = buildSdkUrl('https://api.example.com', 'sess-1')
expect(url).toContain('wss://')
})
test('buildSdkUrl uses v2 path for localhost', () => {
const url = buildSdkUrl('http://localhost:8080', 'sess-abc')
expect(url).toContain('/v2/session_ingress/ws/sess-abc')
})
test('buildSdkUrl uses v1 path for remote', () => {
const url = buildSdkUrl('https://api.example.com', 'sess-abc')
expect(url).toContain('/v1/session_ingress/ws/sess-abc')
})

View File

@@ -39,8 +39,8 @@ export function decodeWorkSecret(secret: string): WorkSecret {
* and /v1/ for production (Envoy rewrites /v1/ → /v2/).
*/
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
const isLocalhost =
apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
const hostname = new URL(apiBaseUrl).hostname
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1'
const protocol = isLocalhost ? 'ws' : 'wss'
const version = isLocalhost ? 'v2' : 'v1'
const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')