diff --git a/src/sandbox/listen-in-range.ts b/src/sandbox/listen-in-range.ts index 264ad9f9..2b41aea9 100644 --- a/src/sandbox/listen-in-range.ts +++ b/src/sandbox/listen-in-range.ts @@ -1,3 +1,21 @@ +/** + * Listen options for srt's loopback listeners. + * + * `exclusive` keeps the handle out of Node's cluster sharing. srt is a + * one-process-one-proxy design — the mux backend binds a pid-scoped unix + * socket and each process mints its own proxyAuthToken — but under `cluster` + * a plain listen() is intercepted by the primary, which shares one handle + * across workers and round robins connections. A sandboxed child of worker A + * would then reach worker B's proxy carrying A's token and get a 407. + */ +export function loopbackListenOptions(port: number): { + port: number + host: string + exclusive: true +} { + return { port, host: '127.0.0.1', exclusive: true } +} + /** * Bind `server` to the first free port in `range`, retrying on EADDRINUSE. * With `range` undefined, binds to ephemeral port 0 once. The Windows WFP diff --git a/src/sandbox/mux-proxy.ts b/src/sandbox/mux-proxy.ts index ab1f3284..ef51934d 100644 --- a/src/sandbox/mux-proxy.ts +++ b/src/sandbox/mux-proxy.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { unlink } from 'node:fs/promises' import { logForDebugging } from '../utils/debug.js' import { getPlatform } from '../utils/platform.js' -import { listenInRange } from './listen-in-range.js' +import { listenInRange, loopbackListenOptions } from './listen-in-range.js' /** * First-byte values that select the SOCKS handler. SOCKS5's greeting is @@ -196,9 +196,12 @@ export function createMuxProxyServer(opts: MuxProxyOptions): MuxProxyServer { // The mux→backend hop originates from the parent process (not the // sandboxed child), so WFP doesn't strictly require it; staying in // range just keeps the port surface predictable. + // Exclusive for the same reason as the front-end: under `cluster` a + // shared handle would let one worker's backend serve another's traffic, + // which the pid-scoped unix socket path already prevents elsewhere. await listenInRange( opts.httpServer, - p => opts.httpServer.listen(p, '127.0.0.1'), + p => opts.httpServer.listen(loopbackListenOptions(p)), opts.httpBackendPortRange, new Set(), ) diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 3cf0d103..62f2de31 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -2,7 +2,7 @@ import { createHttpProxyServer } from './http-proxy.js' import { createSocksProxyServer } from './socks-proxy.js' import type { SocksProxyWrapper } from './socks-proxy.js' import { createMuxProxyServer, type MuxProxyServer } from './mux-proxy.js' -import { listenInRange } from './listen-in-range.js' +import { listenInRange, loopbackListenOptions } from './listen-in-range.js' import { SentinelRegistry } from './credential-sentinel.js' import { MaskedFileStore, @@ -579,9 +579,16 @@ async function startMuxProxyServer( // dispatch to an unbound backend. On Windows the backend's port is // excluded when binding the front-end in the same WFP range. const backendPort = await mux.listenHttpBackend() + // `exclusive: true` keeps this listener out of Node's cluster handle + // sharing. srt is a one-process-one-proxy design — the backend socket is + // pid scoped and each process mints its own proxyAuthToken — but a plain + // listen() under `cluster` is intercepted by the primary, which shares one + // handle and round robins connections across workers. A sandboxed child of + // worker A then reaches worker B's proxy carrying A's token and is answered + // with 407. await listenInRange( mux.server, - p => mux.server.listen(p, '127.0.0.1'), + p => mux.server.listen(loopbackListenOptions(p)), portRange, backendPort !== undefined ? new Set([backendPort]) : new Set(), ) diff --git a/test/sandbox/mux-cluster-exclusive.test.ts b/test/sandbox/mux-cluster-exclusive.test.ts new file mode 100644 index 00000000..5903c9c6 --- /dev/null +++ b/test/sandbox/mux-cluster-exclusive.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test' +import { execFile } from 'node:child_process' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { loopbackListenOptions } from '../../src/sandbox/listen-in-range.js' + +const run = promisify(execFile) + +/** + * gh-458: srt is a one-process-one-proxy design — the mux backend binds a + * pid-scoped unix socket and each process mints its own proxyAuthToken. A + * plain `listen(port, host)` breaks that: under Node's `cluster` the primary + * intercepts listen(), shares a single handle across workers and round robins + * connections. A sandboxed child of worker A then lands on worker B's proxy + * carrying A's token and is answered with 407. + * + * The listener must therefore be exclusive. This drives a real cluster rather + * than asserting on the shape of the call, so it fails if the flag is dropped. + */ +const WORKERS = 3 + +const script = (exclusive: boolean) => ` +import cluster from 'node:cluster' +import net from 'node:net' +if (cluster.isPrimary) { + const ports = new Set() + let ready = 0 + for (let i = 0; i < ${WORKERS}; i++) cluster.fork() + cluster.on('message', (w, m) => { + ports.add(m.port) + if (++ready === ${WORKERS}) { + console.log(ports.size) + for (const id in cluster.workers) cluster.workers[id].kill() + process.exit(0) + } + }) +} else { + const srv = net.createServer() + const done = () => process.send({ port: srv.address().port }) + ${ + exclusive + ? "srv.listen({ port: 0, host: '127.0.0.1', exclusive: true }, done)" + : "srv.listen(0, '127.0.0.1', done)" + } +} +` + +async function distinctPorts(exclusive: boolean): Promise { + const dir = await mkdtemp(join(tmpdir(), 'srt-cluster-')) + const file = join(dir, 'probe.mjs') + await writeFile(file, script(exclusive)) + const { stdout } = await run(process.execPath, [file], { timeout: 30_000 }) + return Number(stdout.trim()) +} + +describe('mux listener under node cluster', () => { + test('a plain listen shares one handle across workers', async () => { + expect(await distinctPorts(false)).toBe(1) + }, 40_000) + + test('an exclusive listen gives every worker its own port', async () => { + expect(await distinctPorts(true)).toBe(WORKERS) + }, 40_000) +}) + +describe('loopbackListenOptions', () => { + test('marks the listener exclusive so cluster cannot share it', () => { + expect(loopbackListenOptions(60080)).toEqual({ + port: 60080, + host: '127.0.0.1', + exclusive: true, + }) + }) +})