CVE-2025-66479
Description
Anthropic Sandbox Runtime is a lightweight sandboxing tool for enforcing filesystem and network restrictions on arbitrary processes at the OS level, without requiring a container. Prior to 0.0.16, due to a bug in sandboxing logic, sandbox-runtime did not properly enforce a network sandbox if the sandbox policy did not configure any allowed domains. This could allow sandboxed code to make network requests outside of the sandbox. A patch for this was released in v0.0.16.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@anthropic-ai/sandbox-runtimenpm | < 0.0.16 | 0.0.16 |
Affected products
1Patches
1bea2930cc1dbFix empty allowedDomains to block network as documented
4 files changed · +646 −61
src/sandbox/linux-sandbox-utils.ts+52 −51 modified@@ -568,65 +568,66 @@ export async function wrapCommandWithSandboxLinux( // ========== NETWORK RESTRICTIONS ========== if (needsNetworkRestriction) { - // Only sandbox if we have network config and Linux bridges - if (!httpSocketPath || !socksSocketPath) { - throw new Error( - 'Linux network sandboxing was requested but bridge socket paths are not available', - ) - } - - // Verify socket files still exist before trying to bind them - if (!fs.existsSync(httpSocketPath)) { - throw new Error( - `Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` + - 'The bridge process may have died. Try reinitializing the sandbox.', - ) - } - if (!fs.existsSync(socksSocketPath)) { - throw new Error( - `Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` + - 'The bridge process may have died. Try reinitializing the sandbox.', - ) - } - + // Always unshare network namespace to isolate network access + // This removes all network interfaces, effectively blocking all network bwrapArgs.push('--unshare-net') - // Bind both sockets into the sandbox - bwrapArgs.push('--bind', httpSocketPath, httpSocketPath) - bwrapArgs.push('--bind', socksSocketPath, socksSocketPath) + // If proxy sockets are provided, bind them into the sandbox to allow + // filtered network access through the proxy. If not provided, network + // is completely blocked (empty allowedDomains = block all) + if (httpSocketPath && socksSocketPath) { + // Verify socket files still exist before trying to bind them + if (!fs.existsSync(httpSocketPath)) { + throw new Error( + `Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` + + 'The bridge process may have died. Try reinitializing the sandbox.', + ) + } + if (!fs.existsSync(socksSocketPath)) { + throw new Error( + `Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` + + 'The bridge process may have died. Try reinitializing the sandbox.', + ) + } - // Add proxy environment variables - // HTTP_PROXY points to the socat listener inside the sandbox (port 3128) - // which forwards to the Unix socket that bridges to the host's proxy server - const proxyEnv = generateProxyEnvVars( - 3128, // Internal HTTP listener port - 1080, // Internal SOCKS listener port - ) - bwrapArgs.push( - ...proxyEnv.flatMap((env: string) => { - const firstEq = env.indexOf('=') - const key = env.slice(0, firstEq) - const value = env.slice(firstEq + 1) - return ['--setenv', key, value] - }), - ) + // Bind both sockets into the sandbox + bwrapArgs.push('--bind', httpSocketPath, httpSocketPath) + bwrapArgs.push('--bind', socksSocketPath, socksSocketPath) - // Add host proxy port environment variables for debugging/transparency - // These show which host ports the Unix socket bridges connect to - if (httpProxyPort !== undefined) { - bwrapArgs.push( - '--setenv', - 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', - String(httpProxyPort), + // Add proxy environment variables + // HTTP_PROXY points to the socat listener inside the sandbox (port 3128) + // which forwards to the Unix socket that bridges to the host's proxy server + const proxyEnv = generateProxyEnvVars( + 3128, // Internal HTTP listener port + 1080, // Internal SOCKS listener port ) - } - if (socksProxyPort !== undefined) { bwrapArgs.push( - '--setenv', - 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', - String(socksProxyPort), + ...proxyEnv.flatMap((env: string) => { + const firstEq = env.indexOf('=') + const key = env.slice(0, firstEq) + const value = env.slice(firstEq + 1) + return ['--setenv', key, value] + }), ) + + // Add host proxy port environment variables for debugging/transparency + // These show which host ports the Unix socket bridges connect to + if (httpProxyPort !== undefined) { + bwrapArgs.push( + '--setenv', + 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', + String(httpProxyPort), + ) + } + if (socksProxyPort !== undefined) { + bwrapArgs.push( + '--setenv', + 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', + String(socksProxyPort), + ) + } } + // If no sockets provided, network is completely blocked (--unshare-net without proxy) } // ========== FILESYSTEM RESTRICTIONS ==========
src/sandbox/sandbox-manager.ts+33 −10 modified@@ -495,12 +495,27 @@ async function wrapWithSandbox( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], } - // Check if network proxy is needed based on allowed domains - // Unix sockets are local IPC and don't require the network proxy + // Check if network config is specified - this determines if we need network restrictions + // Network restriction is needed when: + // 1. customConfig has network.allowedDomains defined (even if empty array = block all) + // 2. OR config has network.allowedDomains defined (even if empty array = block all) + // An empty allowedDomains array means "no domains allowed" = block all network access + const hasNetworkConfig = + customConfig?.network?.allowedDomains !== undefined || + config?.network?.allowedDomains !== undefined + + // Get the actual allowed domains list for proxy filtering const allowedDomains = customConfig?.network?.allowedDomains ?? config?.network.allowedDomains ?? [] + + // Network RESTRICTION is needed whenever network config is specified + // This includes empty allowedDomains which means "block all network" + const needsNetworkRestriction = hasNetworkConfig + + // Network PROXY is only needed when there are domains to filter + // If allowedDomains is empty, we block all network and don't need the proxy const needsNetworkProxy = allowedDomains.length > 0 // Wait for network initialization only if proxy is actually needed @@ -512,9 +527,10 @@ async function wrapWithSandbox( case 'macos': return await wrapCommandWithSandboxMacOS({ command, - needsNetworkRestriction: needsNetworkProxy, - httpProxyPort: getProxyPort(), - socksProxyPort: getSocksProxyPort(), + needsNetworkRestriction, + // Only pass proxy ports if proxy is running (when there are domains to filter) + httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined, + socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined, readConfig, writeConfig, allowUnixSockets: getAllowUnixSockets(), @@ -528,11 +544,18 @@ async function wrapWithSandbox( case 'linux': return wrapCommandWithSandboxLinux({ command, - needsNetworkRestriction: needsNetworkProxy, - httpSocketPath: getLinuxHttpSocketPath(), - socksSocketPath: getLinuxSocksSocketPath(), - httpProxyPort: managerContext?.httpProxyPort, - socksProxyPort: managerContext?.socksProxyPort, + needsNetworkRestriction, + // Only pass socket paths if proxy is running (when there are domains to filter) + httpSocketPath: needsNetworkProxy ? getLinuxHttpSocketPath() : undefined, + socksSocketPath: needsNetworkProxy + ? getLinuxSocksSocketPath() + : undefined, + httpProxyPort: needsNetworkProxy + ? managerContext?.httpProxyPort + : undefined, + socksProxyPort: needsNetworkProxy + ? managerContext?.socksProxyPort + : undefined, readConfig, writeConfig, enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
test/sandbox/integration.test.ts+320 −0 modified@@ -947,3 +947,323 @@ describe('Sandbox Integration Tests', () => { }) }) }) + +/** + * Integration tests for the empty allowedDomains vulnerability fix + * + * These tests verify the ACTUAL network behavior when allowedDomains: [] is specified. + * With the fix: + * - Empty allowedDomains = ALL network access blocked (as documented) + * - Non-empty allowedDomains = Only specified domains allowed + * + * The bug caused empty allowedDomains to allow ALL network access instead. + */ +describe('Empty allowedDomains Network Blocking Integration', () => { + const TEST_DIR = join(process.cwd(), '.sandbox-test-empty-domains') + + beforeAll(async () => { + if (skipIfNotLinux()) { + return + } + + // Create test directory + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }) + } + }) + + afterAll(async () => { + if (skipIfNotLinux()) { + return + } + + // Clean up test directory + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + + await SandboxManager.reset() + }) + + describe('Network blocked with empty allowedDomains', () => { + beforeAll(async () => { + if (skipIfNotLinux()) { + return + } + + // Initialize with empty allowedDomains - should block ALL network + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { + allowedDomains: [], // Empty = block all network (documented behavior) + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: [TEST_DIR], + denyWrite: [], + }, + }) + }) + + it('should block all HTTP requests when allowedDomains is empty', async () => { + if (skipIfNotLinux()) { + return + } + + // Try to access example.com - should be blocked + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 --connect-timeout 2 http://example.com 2>&1 || echo "network_failed"', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + // With empty allowedDomains, network should be completely blocked + // curl should fail with network-related error + const output = (result.stdout + result.stderr).toLowerCase() + + // Network should fail - either connection error, timeout, or "network_failed" echo + const networkBlocked = + output.includes('network_failed') || + output.includes('couldn\'t connect') || + output.includes('connection refused') || + output.includes('network is unreachable') || + output.includes('name or service not known') || + output.includes('timed out') || + output.includes('connection timed out') || + result.status !== 0 + + expect(networkBlocked).toBe(true) + + // Should NOT contain successful HTML response + expect(output).not.toContain('example domain') + expect(output).not.toContain('<!doctype') + }) + + it('should block all HTTPS requests when allowedDomains is empty', async () => { + if (skipIfNotLinux()) { + return + } + + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 --connect-timeout 2 https://example.com 2>&1 || echo "network_failed"', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + const output = (result.stdout + result.stderr).toLowerCase() + + // Network should fail + const networkBlocked = + output.includes('network_failed') || + output.includes('couldn\'t connect') || + output.includes('connection refused') || + output.includes('network is unreachable') || + output.includes('name or service not known') || + output.includes('timed out') || + result.status !== 0 + + expect(networkBlocked).toBe(true) + }) + + it('should block DNS lookups when allowedDomains is empty', async () => { + if (skipIfNotLinux()) { + return + } + + // Try DNS lookup - should fail with no network + const command = await SandboxManager.wrapWithSandbox( + 'host example.com 2>&1 || nslookup example.com 2>&1 || echo "dns_failed"', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + const output = (result.stdout + result.stderr).toLowerCase() + + // DNS should fail when network is blocked + const dnsBlocked = + output.includes('dns_failed') || + output.includes('connection timed out') || + output.includes('no servers could be reached') || + output.includes('network is unreachable') || + output.includes('name or service not known') || + output.includes('temporary failure') || + result.status !== 0 + + expect(dnsBlocked).toBe(true) + }) + + it('should block wget when allowedDomains is empty', async () => { + if (skipIfNotLinux()) { + return + } + + const command = await SandboxManager.wrapWithSandbox( + 'wget -q --timeout=2 -O - http://example.com 2>&1 || echo "wget_failed"', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + const output = (result.stdout + result.stderr).toLowerCase() + + // wget should fail + const wgetBlocked = + output.includes('wget_failed') || + output.includes('failed') || + output.includes('network is unreachable') || + output.includes('unable to resolve') || + result.status !== 0 + + expect(wgetBlocked).toBe(true) + }) + + it('should allow local filesystem operations when network is blocked', async () => { + if (skipIfNotLinux()) { + return + } + + // Even with network blocked, filesystem should work + const testFile = join(TEST_DIR, 'network-blocked-test.txt') + const testContent = 'test content with network blocked' + + const command = await SandboxManager.wrapWithSandbox( + `echo "${testContent}" > ${testFile} && cat ${testFile}`, + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + cwd: TEST_DIR, + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(testContent) + + // Cleanup + if (existsSync(testFile)) { + unlinkSync(testFile) + } + }) + }) + + describe('Network allowed with specific domains', () => { + beforeAll(async () => { + if (skipIfNotLinux()) { + return + } + + // Reinitialize with specific domain allowed + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { + allowedDomains: ['example.com'], // Only example.com allowed + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: [TEST_DIR], + denyWrite: [], + }, + }) + }) + + it('should allow HTTP to explicitly allowed domain', async () => { + if (skipIfNotLinux()) { + return + } + + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 5 http://example.com 2>&1', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // Should succeed and return HTML + expect(result.status).toBe(0) + expect(result.stdout).toContain('Example Domain') + }) + + it('should block HTTP to non-allowed domain', async () => { + if (skipIfNotLinux()) { + return + } + + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 http://anthropic.com 2>&1', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + const output = result.stdout.toLowerCase() + // Should be blocked by proxy + expect(output).toContain('blocked by network allowlist') + }) + }) + + describe('Contrast: empty vs undefined network config', () => { + it('empty allowedDomains should block network', async () => { + if (skipIfNotLinux()) { + return + } + + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { + allowedDomains: [], // Explicitly empty + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: [TEST_DIR], + denyWrite: [], + }, + }) + + const command = await SandboxManager.wrapWithSandbox( + 'curl -s --max-time 2 http://example.com 2>&1 || echo "blocked"', + ) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + + // Should be blocked + const output = (result.stdout + result.stderr).toLowerCase() + const isBlocked = + output.includes('blocked') || + output.includes('couldn\'t connect') || + output.includes('network is unreachable') || + result.status !== 0 + + expect(isBlocked).toBe(true) + expect(output).not.toContain('example domain') + }) + }) +})
test/sandbox/wrap-with-sandbox.test.ts+241 −0 modified@@ -408,5 +408,246 @@ describe('restriction pattern semantics', () => { expect(result).not.toBe(command) expect(result).toContain('sandbox-exec') }) + + // Tests for the empty allowedDomains fix (CVE fix) + // Empty allowedDomains should block all network, not allow all + it('needsNetworkRestriction true without proxy sockets blocks all network on Linux', async () => { + if (getPlatform() !== 'linux') { + return + } + + // Network restriction enabled but no proxy sockets = block all network + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: true, + httpSocketPath: undefined, // No proxy available + socksSocketPath: undefined, // No proxy available + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) + + // Should wrap with --unshare-net to block all network + expect(result).not.toBe(command) + expect(result).toContain('bwrap') + expect(result).toContain('--unshare-net') + // Should NOT contain proxy-related environment variables since no proxy + expect(result).not.toContain('HTTP_PROXY') + }) + + it('needsNetworkRestriction true without proxy ports blocks all network on macOS', async () => { + if (getPlatform() !== 'macos') { + return + } + + // Network restriction enabled but no proxy ports = block all network + const result = await wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: true, + httpProxyPort: undefined, // No proxy available + socksProxyPort: undefined, // No proxy available + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) + + // Should wrap with sandbox-exec + expect(result).not.toBe(command) + expect(result).toContain('sandbox-exec') + // The sandbox profile should NOT contain "(allow network*)" since restrictions are enabled + // Note: We can't easily check the profile content, but we verify it doesn't skip sandboxing + }) + + it('needsNetworkRestriction true with proxy allows filtered network on Linux', async () => { + if (getPlatform() !== 'linux') { + return + } + + // Create temporary socket files for the test + const fs = await import('fs') + const os = await import('os') + const path = await import('path') + const tmpDir = os.tmpdir() + const httpSocket = path.join(tmpDir, `test-http-${Date.now()}.sock`) + const socksSocket = path.join(tmpDir, `test-socks-${Date.now()}.sock`) + + // Create dummy socket files + fs.writeFileSync(httpSocket, '') + fs.writeFileSync(socksSocket, '') + + try { + const result = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: true, + httpSocketPath: httpSocket, + socksSocketPath: socksSocket, + httpProxyPort: 3128, + socksProxyPort: 1080, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) + + // Should wrap with network namespace isolation + expect(result).not.toBe(command) + expect(result).toContain('bwrap') + expect(result).toContain('--unshare-net') + // Should bind the socket files + expect(result).toContain(httpSocket) + expect(result).toContain(socksSocket) + } finally { + // Cleanup + fs.unlinkSync(httpSocket) + fs.unlinkSync(socksSocket) + } + }) + + it('needsNetworkRestriction true with proxy allows filtered network on macOS', async () => { + if (getPlatform() !== 'macos') { + return + } + + const result = await wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: true, + httpProxyPort: 3128, + socksProxyPort: 1080, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + }) + + // Should wrap with sandbox-exec and proxy env vars + expect(result).not.toBe(command) + expect(result).toContain('sandbox-exec') + // Should set proxy environment variables + expect(result).toContain('HTTP_PROXY') + expect(result).toContain('HTTPS_PROXY') + }) + }) +}) + +/** + * Tests for the empty allowedDomains vulnerability fix + * + * These tests verify that when allowedDomains is explicitly set to an empty array [], + * network access is blocked (as documented) rather than allowed (the bug). + * + * Documentation states: "Empty array = no network access" + * Bug behavior: Empty array = full unrestricted network access + * Fixed behavior: Empty array = network isolation enabled, all network blocked + */ +describe('empty allowedDomains network blocking (CVE fix)', () => { + const command = 'curl https://example.com' + + describe('SandboxManager.wrapWithSandbox with empty allowedDomains', () => { + beforeAll(async () => { + if (skipIfUnsupportedPlatform()) { + return + } + // Initialize with domains so proxy starts, then test with empty customConfig + await SandboxManager.initialize({ + network: { + allowedDomains: ['example.com'], + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: ['/tmp'], + denyWrite: [], + }, + }) + }) + + afterAll(async () => { + if (skipIfUnsupportedPlatform()) { + return + } + await SandboxManager.reset() + }) + + it('empty allowedDomains in customConfig triggers network restriction on Linux', async () => { + if (getPlatform() !== 'linux') { + return + } + + const result = await SandboxManager.wrapWithSandbox(command, undefined, { + network: { + allowedDomains: [], // Empty = block all network (documented behavior) + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: ['/tmp'], + denyWrite: [], + }, + }) + + // With the fix, empty allowedDomains should trigger network isolation + expect(result).not.toBe(command) + expect(result).toContain('bwrap') + expect(result).toContain('--unshare-net') + }) + + it('empty allowedDomains in customConfig triggers network restriction on macOS', async () => { + if (getPlatform() !== 'macos') { + return + } + + const result = await SandboxManager.wrapWithSandbox(command, undefined, { + network: { + allowedDomains: [], // Empty = block all network (documented behavior) + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: ['/tmp'], + denyWrite: [], + }, + }) + + // With the fix, empty allowedDomains should trigger sandbox + expect(result).not.toBe(command) + expect(result).toContain('sandbox-exec') + }) + + it('non-empty allowedDomains still works correctly', async () => { + if (skipIfUnsupportedPlatform()) { + return + } + + const result = await SandboxManager.wrapWithSandbox(command, undefined, { + network: { + allowedDomains: ['example.com'], // Specific domain allowed + deniedDomains: [], + }, + filesystem: { + denyRead: [], + allowWrite: ['/tmp'], + denyWrite: [], + }, + }) + + // Should still wrap with sandbox + expect(result).not.toBe(command) + // Should have proxy environment variables for filtering + expect(result).toContain('HTTP_PROXY') + }) + + it('undefined network config in customConfig falls back to main config', async () => { + if (skipIfUnsupportedPlatform()) { + return + } + + const result = await SandboxManager.wrapWithSandbox(command, undefined, { + // No network config - should fall back to main config which has example.com + filesystem: { + denyRead: [], + allowWrite: ['/tmp'], + denyWrite: [], + }, + }) + + // Should wrap with sandbox using main config's network settings + expect(result).not.toBe(command) + // Main config has example.com, so proxy should be set up + expect(result).toContain('HTTP_PROXY') + }) }) })
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-9gqj-5w7c-vx47ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66479ghsaADVISORY
- github.com/anthropic-experimental/sandbox-runtime/commit/bea2930cc1db9c73a1b15acf6dc19c5261aec1f3nvdWEB
- github.com/anthropic-experimental/sandbox-runtime/security/advisories/GHSA-9gqj-5w7c-vx47nvdWEB
News mentions
0No linked articles in our index yet.