diff --git a/src/server/port-reclaim.ts b/src/server/port-reclaim.ts index 97827cb60ae..73ddc7caefa 100644 --- a/src/server/port-reclaim.ts +++ b/src/server/port-reclaim.ts @@ -148,10 +148,58 @@ export function parseListenPidsFromNetstat(output: string, port: number): number return [...new Set(parseListenEntriesFromNetstat(output, port).map(entry => entry.pid))]; } +/** + * Field names `ss -p` is known to emit inside a `users:` tuple. comm names are printed + * unescaped and are attacker-controlled, but bounded to 15 bytes (TASK_COMM_LEN - 1): + * a forged complete tuple needs `",pid=N,fd=N),("` — closing one tuple and opening the + * next leaves no room for a nonempty name — and a forged in-tuple field needs a key + * outside this list to stay under the bound, so it trips the grammar check instead. + */ +const SS_OWNER_FIELD_KEYS = new Set(["fd", "ino", "sk", "v6only"]); + +/** + * Strictly parse a `users:(("name",pid=N,fd=N)[,("name2",...)])` column, returning every + * attributed PID, or null when the column deviates from the grammar anywhere — a row that + * cannot be trusted must not attribute an owner at all. Every accepted tuple must carry + * its own `fd=`: a forged tuple fragment emitted inside a comm (a 15-byte comm has room + * for `a",pid=N),("b` but never for a full tuple plus `fd=`) supplies only `pid=`, so + * its PID must never reach the owner list. + */ +function parseSsOwnerPids(field: string): number[] | null { + if (!field.startsWith("users:(")) return null; + const pids: number[] = []; + let at = "users:(".length; + while (field.startsWith("(", at)) { + at += 1; + // ss prints comm raw between quotes with no escaping; a quote inside the name + // therefore ends it early and the rest of the name lands in field position. + const name = /^"[^"\n]*"/.exec(field.slice(at)); + if (name === null || name[0] === `""`) return null; + at += name[0].length; + const pid = /^,pid=(\d+)/.exec(field.slice(at)); + if (pid === null) return null; + at += pid[0].length; + let hasFd = false; + for (;;) { + const kv = /^,([a-z_]+)=([^,"()\s]+)/.exec(field.slice(at)); + if (kv === null) break; + if (!SS_OWNER_FIELD_KEYS.has(kv[1]!)) return null; + if (kv[1] === "fd") hasFd = true; + at += kv[0].length; + } + if (field[at] !== ")" || !hasFd) return null; + pids.push(Number(pid[1])); + at += 1; + if (field.startsWith(",(", at)) at += 1; + } + return field[at] === ")" && field.slice(at + 1).trim() === "" ? pids : null; +} + /** * Parse `ss -Hltnp` rows for a port, keeping each distinct PID/address pair. A row - * without a `pid=` attribution (another user's socket) is dropped rather than - * reported unverifiable. Exported for unit tests. + * without a `pid=` attribution (another user's socket), or whose `users:` column + * does not parse cleanly, is dropped rather than reported unverifiable. Exported + * for unit tests. */ export function parseListenEntriesFromSs(output: string, port: number): ListenEntry[] { const entries = new Map(); @@ -163,11 +211,13 @@ export function parseListenEntriesFromSs(output: string, port: number): ListenEn // LISTEN users:(...) const localIdx = parts.findIndex(part => part.endsWith(portSuffix) || part.endsWith(`]:${port}`)); if (localIdx < 0) continue; - const pidMatch = /pid=(\d+)/.exec(line); - const pid = pidMatch ? Number(pidMatch[1]) : NaN; - if (Number.isSafeInteger(pid) && pid > 0) { - const address = normalizeListenAddress(parts[localIdx]); - entries.set(`${pid}|${address}`, { pid, address }); + const usersIdx = line.indexOf("users:("); + if (usersIdx < 0) continue; + const ownerPids = parseSsOwnerPids(line.slice(usersIdx)); + if (ownerPids === null) continue; + const address = normalizeListenAddress(parts[localIdx]); + for (const pid of ownerPids) { + if (Number.isSafeInteger(pid) && pid > 0) entries.set(`${pid}|${address}`, { pid, address }); } } return [...entries.values()]; diff --git a/structure/remote-link.md b/structure/remote-link.md index cfa1e878c26..65531819188 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -20,7 +20,7 @@ Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a During enrollment, `src/client/link-join.ts` watches the spawned SSH tunnel through its 100 ms spawn grace, readiness checks and the connection attempt. Before an unauthenticated `/readyz` probe and again before the keyed request, the only LISTEN owner serving `127.0.0.1:` must be that tunnel PID. Both requests use `redirect: "manual"`; only a 401 challenge permits the keyed request. A failed, empty, foreign or ambiguous ownership recheck withholds the key and reaches the same 15-second deadline check and up-to-100 ms polling delay as any other not-ready iteration. Repeated recheck failures therefore reach rollback instead of bypassing it. The deadline is checked between operations, not an independent per-fetch cancellation timer. An observed tunnel exit winning the readiness or connection race fails the join and runs compensation. -The enrollment scanner in `src/server/port-reclaim.ts` retains each distinct normalized `(PID, bound address)` pair from Windows `netstat` or the POSIX `lsof`, `ss`, then `netstat` fallback chain. It filters entries for the requested loopback address before deduplicating PIDs, so another socket owned by the same process cannot overwrite the relevant listener. Duplicate rows and IPv4-mapped aliases of the same address still collapse, and the PID-only API continues to return unique PIDs. This enrollment scan does not replace the runtime supervisor's asynchronous ownership check described under [Client link transport](#client-link-transport); the check-to-connect race described there remains. +The enrollment scanner in `src/server/port-reclaim.ts` retains each distinct normalized `(PID, bound address)` pair from Windows `netstat` or the POSIX `lsof`, `ss`, then `netstat` fallback chain. For `ss`, it reads PID owner fields outside the quoted, attacker-controlled process name. It filters entries for the requested loopback address before deduplicating PIDs, so another socket owned by the same process cannot overwrite the relevant listener. Duplicate rows and IPv4-mapped aliases of the same address still collapse, and the PID-only API continues to return unique PIDs. This enrollment scan does not replace the runtime supervisor's asynchronous ownership check described under [Client link transport](#client-link-transport); the check-to-connect race described there remains. `src/client/link-state.ts` stores `/link/client-link.json` with mode 0600. The sidecar contains exactly `alias`, `hubHostKeyFingerprint`, `tunnelPort`, `peerListenerPort` and `linkId`; it contains no key. The client tunnel port uses `MIN_LINK_PORT = 1024` through `MAX_LINK_PORT = 65535` and `isLinkPort`; the Home listener port keeps its existing 1–65535 contract. A dashboard join picks a free port at random from `JOIN_TUNNEL_PORT_MIN = 20000` through `JOIN_TUNNEL_PORT_MAX = 29999` (`chooseJoinTunnelPort` in `src/client/link-join.ts`), below the macOS, Windows and Linux ephemeral ranges, so an outgoing connection rarely holds the port when the tunnel comes back after a reboot. The persisted port of an existing link is never rewritten. diff --git a/tests/server/port-reclaim.test.ts b/tests/server/port-reclaim.test.ts index be63b0c449a..8025e77317a 100644 --- a/tests/server/port-reclaim.test.ts +++ b/tests/server/port-reclaim.test.ts @@ -151,12 +151,40 @@ describe("listen-entry parsers keep the bound address", () => { const output = [ "LISTEN 0 128 127.0.0.1:10100 0.0.0.0:* users:((\"bun\",pid=4242,fd=20))", "LISTEN 0 128 127.0.0.2:10100 0.0.0.0:* users:((\"foreign\",pid=7777,fd=6))", + "LISTEN 0 128 127.0.0.3:10100 0.0.0.0:* users:((\"pid=4242\",pid=9999,fd=4))", "LISTEN 0 128 127.0.0.1:10100 0.0.0.0:*", "LISTEN 0 511 *:22 *:* users:((\"sshd\",pid=1,fd=3))", ].join("\n"); expect(parseListenEntriesFromSs(output, 10100)).toEqual([ { pid: 4242, address: "127.0.0.1" }, { pid: 7777, address: "127.0.0.2" }, + { pid: 9999, address: "127.0.0.3" }, + ]); + }); + + test("ss rows with a forged owner inside an embedded-quote process name are dropped", () => { + // ss prints comm inside quotes without escaping it, so these rows are what the + // kernel actually prints for the crafted 15-byte task names on the right. + const output = [ + // comm `x",pid=4141,"` — the forged pid leads after naive quote stripping. + 'LISTEN 0 128 127.0.0.1:10100 0.0.0.0:* users:(("x",pid=4141,"",pid=9999,fd=4))', + // comm `",pid=4141,fd=1),("` — a complete forged tuple in front of the real one. + 'LISTEN 0 128 127.0.0.2:10100 0.0.0.0:* users:(("",pid=4141,fd=1),("",pid=9999,fd=4))', + // comm `x",pid=4141,f=9` — a forged in-tuple field with a non-ss key. + 'LISTEN 0 128 127.0.0.3:10100 0.0.0.0:* users:(("x",pid=4141,f=9",pid=9999,fd=4))', + // comm `x",pid=4141,fd=9` — even an ss key cannot rescue a forged field. + 'LISTEN 0 128 127.0.0.4:10100 0.0.0.0:* users:(("x",pid=4141,fd=9",pid=9999,fd=4))', + // comm `a",pid=123),("b` — a forged pid lands in a tuple of its own, but that + // fragment carries no fd= so the row is rejected instead of adopting pid 123. + 'LISTEN 0 128 127.0.0.7:10100 0.0.0.0:* users:(("a",pid=123),("b",pid=9999,fd=4))', + // A genuinely shared socket still reports every owner tuple. + 'LISTEN 0 128 127.0.0.5:10100 0.0.0.0:* users:(("bun",pid=4242,fd=20),("worker",pid=4243,fd=3))', + // Trailing garbage after the column is rejected too. + 'LISTEN 0 128 127.0.0.6:10100 0.0.0.0:* users:(("bun",pid=4244,fd=20))extra', + ].join("\n"); + expect(parseListenEntriesFromSs(output, 10100)).toEqual([ + { pid: 4242, address: "127.0.0.5" }, + { pid: 4243, address: "127.0.0.5" }, ]); });