Skip to content
Merged
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
64 changes: 57 additions & 7 deletions src/server/port-reclaim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ListenEntry>();
Expand All @@ -163,11 +211,13 @@ export function parseListenEntriesFromSs(output: string, port: number): ListenEn
// LISTEN <recv-q> <send-q> <local-addr:port> <peer-addr:port> 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()];
Expand Down
2 changes: 1 addition & 1 deletion structure/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<tunnelPort>` 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 `<configDir>/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.

Expand Down
28 changes: 28 additions & 0 deletions tests/server/port-reclaim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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" },
]);
});

Expand Down
Loading