Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/sandbox/listen-in-range.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/sandbox/mux-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
Expand Down
11 changes: 9 additions & 2 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
)
Expand Down
76 changes: 76 additions & 0 deletions test/sandbox/mux-cluster-exclusive.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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,
})
})
})