diff --git a/README.md b/README.md index 14d89686..a14ec265 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ Uses an **allow-only pattern** - all network access is denied by default. - `network.deniedDomains` - Array of denied domains (checked first, takes precedence over allowedDomains). Same `:port` suffix, and a bare `*` (or `*:22`) is accepted for deny-all. - `network.deniedDomainReasons` - Optional map from a `deniedDomains` entry (matched by exact string) to a model-facing reason that appears in the `` line when that entry denies a connection — say what is blocked and the sanctioned alternative (e.g. `{"github.com:22": "SSH pushes to GitHub are blocked; use an https:// remote"}`). Entries without a reason report a generic one. For SSH destinations (port 22), the reason is also delivered in-band: an SSH client tunneled through a no-auth SOCKS ProxyCommand (e.g. BSD `nc -X 5`) receives a pre-key-exchange SSH disconnect whose description is the reason, which OpenSSH prints verbatim — keep such reasons under ~400 ASCII characters, imperative first, since OpenSSH truncates and escapes non-ASCII. - `network.allowLocalBinding` - Allow binding to local ports (boolean, default: false) +- `network.httpProxyDualStack` - On macOS, also listen for HTTP proxy connections on `::1` while continuing to advertise `localhost` (boolean, default: false). **TLS termination** (`network.tlsTerminate`, experimental): when set, HTTPS CONNECTs are terminated in-process so SRT can see (and filter, via `network.filterRequest`) the decrypted requests. The sandboxed process is pointed at a trust bundle containing the MITM CA (`caCertPath`/`caKeyPath`, or an ephemeral CA if omitted) plus the host's regular roots, so proxy-minted certificates and real upstream certificates both verify. diff --git a/src/sandbox/mux-proxy.ts b/src/sandbox/mux-proxy.ts index ab1f3284..1e724f70 100644 --- a/src/sandbox/mux-proxy.ts +++ b/src/sandbox/mux-proxy.ts @@ -45,8 +45,10 @@ export interface MuxProxyOptions { } export interface MuxProxyServer { - /** The front-end TCP listener. Call `.listen()` on this. */ + /** The IPv4 front-end TCP listener. Call `.listen()` on this. */ server: Server + /** Optional IPv6 HTTP front end. */ + ipv6Server: Server /** Bound front-end port, once listening. */ getPort(): number | undefined /** @@ -60,7 +62,7 @@ export interface MuxProxyServer { listenHttpBackend(): Promise /** Tear down front-end, backend, and all open client sockets. */ close(): Promise - /** unref() both listeners so they don't keep the event loop alive. */ + /** Prevent listeners from keeping the event loop alive. */ unref(): void } @@ -137,12 +139,16 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer { upstream.pipe(client) } - const server = createServer(client => { + function trackClient(client: Socket): void { openSockets.add(client) client.once('close', () => openSockets.delete(client)) client.on('error', err => logForDebugging(`mux: client socket error: ${err.message}`), ) + } + + const server = createServer(client => { + trackClient(client) const timer = setTimeout(() => { logForDebugging('mux: first-byte timeout; destroying connection') @@ -168,8 +174,14 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer { }) }) + const ipv6Server = createServer(client => { + trackClient(client) + dispatchHttp(client) + }) + return { server, + ipv6Server, getPort(): number | undefined { const addr = server.address() return addr && typeof addr === 'object' ? addr.port : undefined @@ -212,7 +224,14 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer { async close(): Promise { for (const s of openSockets) s.destroy() openSockets.clear() - await new Promise(resolve => server.close(() => resolve())) + await Promise.all( + [server, ipv6Server] + .filter(listener => listener.listening) + .map( + listener => + new Promise(resolve => listener.close(() => resolve())), + ), + ) // The mux owns httpServer's listen lifecycle, so it owns close too. // sandbox-manager.reset() additionally calls forceCloseHttpServer() // for closeAllConnections() semantics; double-close is a no-op. @@ -225,6 +244,7 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer { }, unref(): void { server.unref() + if (ipv6Server.listening) ipv6Server.unref() opts.httpServer.unref() }, } diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index baa6052d..0ca13ac5 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -766,6 +766,12 @@ export const NetworkConfigSchema = z.object({ .describe( 'macOS only: Additional XPC/Mach service names to allow looking up. Supports trailing-wildcard prefix matching (e.g., "2BUA8C4S2C.com.1password.*"). Needed for tools like 1Password CLI, Playwright, or the iOS Simulator that communicate via XPC.', ), + httpProxyDualStack: z + .boolean() + .optional() + .describe( + 'macOS only: Also listen on IPv6 loopback when the runtime owns the HTTP proxy. The proxy URL remains on localhost.', + ), httpProxyPort: z .number() .int() diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 3cf0d103..9c5ca8f6 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -507,6 +507,7 @@ function shouldTerminateTLSForHost(host: string): boolean { async function startMuxProxyServer( sandboxAskCallback: SandboxAskCallback | undefined, portRange: readonly [number, number] | undefined, + enableIpv6Http: boolean, ): Promise { const injectCredentials = buildCredentialInjector() const injectBodyCredentials = buildBodyCredentialInjector() @@ -589,8 +590,22 @@ async function startMuxProxyServer( if (muxPort === undefined) { throw new Error('Failed to get mux proxy server port') } + if (enableIpv6Http) { + await new Promise((resolve, reject) => { + mux.ipv6Server.once('error', reject) + mux.ipv6Server.listen( + { host: '::1', port: muxPort, ipv6Only: true }, + () => { + mux.ipv6Server.removeListener('error', reject) + resolve() + }, + ) + }) + } mux.unref() - logForDebugging(`Mux proxy (HTTP+SOCKS) listening on localhost:${muxPort}`) + logForDebugging( + `Mux proxy listening on 127.0.0.1${enableIpv6Http ? ' and ::1' : ''}:${muxPort}`, + ) return muxPort } @@ -890,7 +905,13 @@ async function initialize( config.network.httpProxyPort === undefined || config.network.socksProxyPort === undefined const muxPort = needLocalProxy - ? await startMuxProxyServer(sandboxAskCallback, portRange) + ? await startMuxProxyServer( + sandboxAskCallback, + portRange, + getPlatform() === 'macos' && + config.network.httpProxyPort === undefined && + config.network.httpProxyDualStack === true, + ) : undefined const httpProxyPort = config.network.httpProxyPort ?? muxPort! const socksProxyPort = config.network.socksProxyPort ?? muxPort! diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index a041958b..95325f6b 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -95,6 +95,7 @@ describe('Config Validation', () => { allowUnixSockets: ['/var/run/docker.sock'], allowAllUnixSockets: false, allowLocalBinding: true, + httpProxyDualStack: true, }, filesystem: { denyRead: ['/etc/shadow'], diff --git a/test/sandbox/proxy-env-vars.test.ts b/test/sandbox/proxy-env-vars.test.ts index 5c99c65a..1ea84e06 100644 --- a/test/sandbox/proxy-env-vars.test.ts +++ b/test/sandbox/proxy-env-vars.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'bun:test' -import { createServer } from 'node:http' +import { createServer, request } from 'node:http' import type { Server } from 'node:http' import type { AddressInfo } from 'node:net' import { @@ -9,7 +9,7 @@ import { import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js' import { spawnAsync } from '../helpers/spawn.js' -import { isLinux } from '../helpers/platform.js' +import { isLinux, isMacOS } from '../helpers/platform.js' describe('generateProxyEnvVars', () => { it('sets CLOUDSDK_PROXY_TYPE to http (gcloud rejects "https")', () => { @@ -30,6 +30,42 @@ describe('generateProxyEnvVars', () => { expect(env.some(v => v.startsWith('CLOUDSDK_PROXY_'))).toBe(false) }) + it.if(isMacOS)( + 'can add an IPv6 listener without changing the proxy URL', + async () => { + try { + await SandboxManager.initialize({ + network: { + allowedDomains: [], + deniedDomains: [], + httpProxyDualStack: true, + }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) + const port = SandboxManager.getProxyPort() + const wrapped = await SandboxManager.wrapWithSandbox('true') + expect(wrapped).toContain('@localhost:') + + const status = await new Promise( + (resolve, reject) => { + const req = request( + { host: '::1', port, path: 'http://example.invalid/' }, + response => { + response.resume() + resolve(response.statusCode) + }, + ) + req.once('error', reject) + req.end() + }, + ) + expect(status).toBe(407) + } finally { + await SandboxManager.reset() + } + }, + ) + describe('GRPC_PROXY', () => { const grpcNames = ['GRPC_PROXY', 'grpc_proxy']