jail: decide socketpair by its family, so Node can start a process

TypeScript and JavaScript were the two languages the new image could not serve.
The daemon said why and kept going: `language server did not start … spawn
EPERM`, once per root, while the other eight answered.

socketpair was denied beside socket, on the reading that a served process must
be able to obtain no socket fd at all. But libuv gives a child its stdio through
a socketpair, so what that denied was not a network — it was Node starting a
process, and typescript-language-server exists to run tsserver.

A socketpair reaches nothing: both ends come back already connected to each
other, in AF_UNIX, inside this jail's namespaces. It cannot be connect()ed
elsewhere, and a peer that could pass a network fd over it would have to be
outside the jail, where nothing is. socket() is still denied, so there is still
no way to obtain a fd to a network.

The filter now reads args[0] and admits AF_UNIX alone. Allowing it outright
would have been equivalent today — the kernel implements socketpair for no other
family — but that is a fact about the kernel, not a property of this program,
and the claim in the header is worth keeping true by construction.

The arithmetic is the risk: those two jump edges are hand-computed offsets over
a list whose length changes with the phase, and an off-by-one reads as ALLOW.
TestSocketpairIsDecidedByItsFamily interprets the program for AF_UNIX and for
five families that must fail, in both phases, and pins that the carve-out did
not widen to socket().

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-09 12:17:33 -07:00
co-authored by Hanzo Dev
parent 1786dda5ca
commit 481d69e8b7
5 changed files with 137 additions and 33 deletions
+24 -8
View File
@@ -24,15 +24,25 @@ a definition that left the repository and landed in the dependency cache.
| | FETCH | SERVE |
|---|---|---|
| runs | `go mod download` — first-party toolchain, download-only | `gopls serve -mode=stdio` — third-party, parses tenant source |
| socket | permitted | **denied** by seccomp (`socket`, `socketpair`, and the io_uring family) |
| socket | permitted | **denied** by seccomp (`socket` and the io_uring family; `socketpair` is allowed for AF_UNIX only, which is a pipe to your own child and reaches nothing) |
| tree | writable | read-only |
| dependency cache | writable | read-only |
| namespaces | new user/mount/pid/ipc/uts, minimal chroot, rlimits, `no_new_privs` | same |
The phase is not a field anyone sets — it is `jail.Fetch` or `jail.Serve`, two
functions, so there is no value to get wrong. The only difference between their
seccomp filters is `socket`/`socketpair`, and that is one two-line function
(`denied` in `internal/jail/jail_linux.go`).
seccomp filters is `socket`, and that is one two-line function (`denied` in
`internal/jail/jail_linux.go`).
`socketpair` used to be denied beside it and is not any more. It reaches nothing
— both ends come back already connected to each other, in AF_UNIX, inside this
jail's namespaces — but it is how libuv gives a child its stdio, so denying it
did not stop a network, it stopped **Node starting a process**. TypeScript's
server exists to run `tsserver`, so it died with `spawn EPERM` while every other
language kept answering. The filter now decides `socketpair` on its FAMILY, the
one argument it reads, so "no fd to a network" stays a property of the program
rather than a fact about which families the kernel implements
(`TestSocketpairIsDecidedByItsFamily`).
Fetches that would run DEPENDENCY-authored code (`uv sync` building an sdist,
`npm install` running postinstall, `cargo build` running build.rs) do not run at
@@ -387,8 +397,14 @@ BINARY and a self-exec'd test suite proves nothing.
`python-sdk/pkg/hanzo-tools-lsp`) with `repo`/`rev` parameters — a file goes
to the local stdio server, a repo goes to `/v1/code/lsp/*`. Not a new tool.
4. **More languages** = install the server in the Dockerfile and add its entry.
DONE for the nine above. Two of them are worth watching: rust-analyzer runs
`cargo metadata` against a READ-ONLY `CARGO_HOME`, and cargo wants to take a
lock there; jdtls is a JVM under a 6 GiB address-space rlimit. Both are the
shape of problem Go had with `/proc`, and both degrade to a language that
does not answer rather than a daemon that does not run.
DONE for the nine above, and verified on the gVisor node at 0.1.6: Go,
Python, Rust, C, C++, Java, PHP and Ruby all answered `locate definition`
jailed, Ruby into the interpreter's own library with `external: true`.
TypeScript and JavaScript did not, for the `socketpair` reason above — one
language's server failing is one language, which is the graceful-degrade
design doing its job and the reason the other eight shipped anyway.
Two things a repo needs before its dependencies resolve, neither a bug:
`cargo fetch --locked` wants a committed `Cargo.lock`, and `npm ci` wants a
`package-lock.json`. Without one the fetch is skipped, logged, and the server
still answers about the tree's own source.
+1 -1
View File
@@ -76,7 +76,7 @@ test:
# a number at or below what is published publishes the next patch above the
# registry. So this line is the floor, not the output, and it can never mint a
# tag that already covers different bytes.
version: 0.1.6
version: 0.1.7
images:
- name: lsp
+45 -6
View File
@@ -491,6 +491,8 @@ func Probe(ctx context.Context, root string) error {
// AUDIT_ARCH_X86_64 with amd64 — the arch check alone does not catch it, yet
// every x32 number is ORed with that bit and would dodge the numeric deny;
// - returns ERRNO(EPERM) for the denied numbers;
// - allows socketpair for AF_UNIX ONLY, and refuses every other family — see
// the note on it below;
// - ALLOWs everything else, because a toolchain's syscall surface is wide and
// an allow-list of it would be a list of everything.
@@ -520,10 +522,26 @@ var escape = []uint32{
syscall.SYS_KEXEC_LOAD, sysKexecFileLoad,
}
// network is denied in the SERVE phase only. With no call that returns a socket
// fd, connect/sendto/recvfrom are unreachable: egress is impossible by
// construction rather than filtered by policy.
var network = []uint32{syscall.SYS_SOCKET, syscall.SYS_SOCKETPAIR}
// network is denied in the SERVE phase only. socket() is the only call that
// hands out a fd to a NETWORK, so with it denied connect/sendto/recvfrom are
// unreachable: egress is impossible by construction rather than filtered by
// policy.
//
// socketpair is NOT here, and that is a correction rather than a concession.
// It was, and the cost was TypeScript: a socketpair is how libuv gives a child
// its stdio, so denying it does not stop a network — it stops Node starting a
// process at all, and typescript-language-server exists to run tsserver. It
// failed with `spawn EPERM` and every other language kept working, which is the
// graceful-degrade design telling the truth about a boundary drawn in the wrong
// place.
//
// A socketpair reaches nothing. Both ends come back already connected to each
// other, in AF_UNIX, inside this jail's own namespaces; it cannot be connect()ed
// elsewhere, and a peer that could pass a network fd over it would have to be
// outside the jail, where nothing is. Filtering it to AF_UNIX (filter, below)
// keeps the claim above exact — no INET fd by construction — rather than resting
// on the fact that the kernel implements socketpair for no other family.
var network = []uint32{syscall.SYS_SOCKET}
// denied is the deny-list for p. The ONE difference between the phases lives
// here, in a two-line function, so "what does Fetch have that Serve does not"
@@ -557,6 +575,7 @@ const (
rlimitNproc = 6 // RLIMIT_NPROC, absent from Go's syscall package
prSetNoNewPrivs = 38 // PR_SET_NO_NEW_PRIVS
afUnix = 1 // AF_UNIX, the only family socketpair may name here
)
// Filter return actions and arch markers. Package-level so the builder and its
@@ -580,6 +599,12 @@ func filter(p phase) []insn {
ret = 0x06 | 0x00 // BPF_RET|BPF_K
atNR = 0 // seccomp_data.nr
atArc = 4 // seccomp_data.arch
// seccomp_data.args[0] is 64 bits at offset 16; cBPF loads 32, and on a
// little-endian machine that offset IS the low half. That half is the
// whole of the answer here: sys_socketpair takes an `int family`, so the
// kernel truncates the register to exactly these bits, and the filter and
// the kernel therefore read the same number.
atArg0 = 16
)
nrs := denied(p)
prog := []insn{
@@ -593,14 +618,28 @@ func filter(p phase) []insn {
{code: ld, k: atNR},
{code: jset, jt: 0, jf: 1, k: x32Bit},
{code: ret, k: retKill},
// 3. socketpair, decided on its FAMILY rather than its number — the one
// place this filter reads an argument. AF_UNIX is a pipe between a process
// and its own child and reaches nothing (see network, above); every other
// family is refused, so "no fd to a network" stays a property of the
// program and not a fact about which families the kernel bothered to
// implement.
//
// Reading args[0] CLOBBERS the accumulator, so this arm never falls
// through — both edges are terminals. A number that is not socketpair
// skips both instructions with the number still loaded.
{code: jeq, jt: 0, jf: 2, k: syscall.SYS_SOCKETPAIR},
{code: ld, k: atArg0},
{code: jeq, jt: uint8(len(nrs)), jf: uint8(len(nrs)) + 1, k: afUnix},
}
// 3. Each denied number jumps forward to the shared ERRNO terminal, over the
// 4. Each denied number jumps forward to the shared ERRNO terminal, over the
// remaining compares and over the ALLOW. jt is an offset from the NEXT
// instruction and is 8 bits; the list is far below 250 long.
for i, nr := range nrs {
prog = append(prog, insn{code: jeq, jt: uint8(len(nrs)-1-i) + 1, k: nr})
}
// 4. The two terminals.
// 5. The two terminals. Everything above that jumps forward is measured to
// land on one of these two, which is why they are appended last and once.
return append(prog, insn{code: ret, k: retAllow}, insn{code: ret, k: retErrno})
}
+66 -17
View File
@@ -41,40 +41,73 @@ func TestFilterIsTheBoundary(t *testing.T) {
// A different audit arch is KILLed whatever the number: the i386 ABI's
// numbers mean other things and would sail past the compares below.
const auditArchI386 = 0x40000003
if got := eval(auditArchI386, 41); got != retKill {
if got := eval(call{arch: auditArchI386, nr: 41}); got != retKill {
t.Fatalf("%s: i386 arch = 0x%x, want KILL", p, got)
}
// Every denied number reaches the ERRNO terminal, and its x32 spelling is
// KILLed rather than slipping past the numeric compare.
for _, nr := range denied(p) {
if got := eval(auditArchAMD64, nr); got != retErrno {
if got := eval(call{arch: auditArchAMD64, nr: nr}); got != retErrno {
t.Fatalf("%s: denied nr %d = 0x%x, want ERRNO", p, nr, got)
}
if got := eval(auditArchAMD64, nr|x32Bit); got != retKill {
if got := eval(call{arch: auditArchAMD64, nr: nr | x32Bit}); got != retKill {
t.Fatalf("%s: x32 of denied nr %d = 0x%x, want KILL", p, nr, got)
}
}
// The x32 ABI is refused wholesale, not only for the denied numbers.
if got := eval(auditArchAMD64, 0|x32Bit); got != retKill {
if got := eval(call{arch: auditArchAMD64, nr: 0 | x32Bit}); got != retKill {
t.Fatalf("%s: x32 read = 0x%x, want KILL", p, got)
}
// read(0) and openat(257) are not denied — a toolchain's surface is wide
// and the filter must not become an allow-list of everything.
for _, nr := range []uint32{0, 257} {
if got := eval(auditArchAMD64, nr); got != retAllow {
if got := eval(call{arch: auditArchAMD64, nr: nr}); got != retAllow {
t.Fatalf("%s: nr %d = 0x%x, want ALLOW", p, nr, got)
}
}
// io_uring is denied in BOTH phases: a socket-only deny is not egress
// containment, because IORING_OP_SOCKET issues no socket() syscall.
for _, nr := range []uint32{sysIoUringSetup, sysIoUringEnter, sysIoUringRegister} {
if got := eval(auditArchAMD64, nr); got != retErrno {
if got := eval(call{arch: auditArchAMD64, nr: nr}); got != retErrno {
t.Fatalf("%s: io_uring nr %d = 0x%x, want ERRNO", p, nr, got)
}
}
}
}
// TestSocketpairIsDecidedByItsFamily pins the one place this filter reads an
// ARGUMENT, in both phases.
//
// AF_UNIX has to pass or Node cannot start a child at all — libuv gives a child
// its stdio through a socketpair, so denying it cost TypeScript its server
// rather than costing anyone a network. Every other family has to fail, or the
// claim that no fd to a network exists stops being a property of this program
// and becomes a fact about which families the kernel implements.
//
// The two edges of that compare are the whole test: they are hand-computed
// offsets over a list whose length changes with the phase, so an off-by-one
// here reads as ALLOW for a family that should be refused.
func TestSocketpairIsDecidedByItsFamily(t *testing.T) {
for _, p := range []phase{serve, fetch} {
eval := interpreter(t, filter(p))
if got := eval(call{arch: auditArchAMD64, nr: syscall.SYS_SOCKETPAIR, arg0: afUnix}); got != retAllow {
t.Errorf("%s: socketpair(AF_UNIX) = 0x%x, want ALLOW — a language server cannot fork without it", p, got)
}
// AF_INET, AF_INET6, AF_NETLINK, AF_PACKET, and a family nobody named.
for _, family := range []uint32{0, 2, 10, 16, 17, 40} {
if got := eval(call{arch: auditArchAMD64, nr: syscall.SYS_SOCKETPAIR, arg0: family}); got != retErrno {
t.Errorf("%s: socketpair(family %d) = 0x%x, want ERRNO", p, family, got)
}
}
// The carve-out is for socketpair alone: socket() is still how a network
// fd is obtained, and the serve phase still refuses it whatever it names.
if got := eval(call{arch: auditArchAMD64, nr: syscall.SYS_SOCKET, arg0: afUnix}); p == serve && got != retErrno {
t.Errorf("serve: socket(AF_UNIX) = 0x%x, want ERRNO — the carve-out must not have widened to socket", got)
}
}
}
// TestPhasesDifferOnlyByTheNetwork is the invariant stated as a test: Serve can
// obtain no socket, Fetch can, and that is the ONLY thing that separates them.
// If someone ever adds a second difference, or removes this one, this fails.
@@ -83,16 +116,17 @@ func TestPhasesDifferOnlyByTheNetwork(t *testing.T) {
fetchEval := interpreter(t, filter(fetch))
for _, nr := range network {
if got := serveEval(auditArchAMD64, nr); got != retErrno {
if got := serveEval(call{arch: auditArchAMD64, nr: nr}); got != retErrno {
t.Fatalf("serve: nr %d = 0x%x, want ERRNO — an untrusted process could open a socket", nr, got)
}
if got := fetchEval(auditArchAMD64, nr); got != retAllow {
if got := fetchEval(call{arch: auditArchAMD64, nr: nr}); got != retAllow {
t.Fatalf("fetch: nr %d = 0x%x, want ALLOW — the fetch phase could not download", nr, got)
}
}
// Everything else agrees.
for _, nr := range escape {
if serveEval(auditArchAMD64, nr) != retErrno || fetchEval(auditArchAMD64, nr) != retErrno {
if serveEval(call{arch: auditArchAMD64, nr: nr}) != retErrno ||
fetchEval(call{arch: auditArchAMD64, nr: nr}) != retErrno {
t.Fatalf("nr %d is not denied in both phases", nr)
}
}
@@ -279,20 +313,35 @@ func covers(a, b string) bool {
return a == b || strings.HasPrefix(b, strings.TrimSuffix(a, "/")+"/")
}
// interpreter returns a function that runs prog against a synthetic (arch, nr)
// the way the kernel's cBPF engine does, for the four opcodes this filter uses.
func interpreter(t *testing.T, prog []insn) func(arch, nr uint32) uint32 {
// call is one synthetic seccomp_data — the kernel's input to the filter, named
// rather than passed as a growing list of positions, because the filter now
// reads an ARGUMENT and a caller that got arch and arg0 the wrong way round
// would still compile.
type call struct {
arch uint32
nr uint32
arg0 uint32
}
// interpreter returns a function that runs prog against one call the way the
// kernel's cBPF engine does, for the four opcodes this filter uses.
func interpreter(t *testing.T, prog []insn) func(call) uint32 {
t.Helper()
return func(arch, nr uint32) uint32 {
return func(c call) uint32 {
var a uint32
for pc := 0; pc < len(prog); {
f := prog[pc]
switch f.code {
case 0x20: // BPF_LD|BPF_W|BPF_ABS
if f.k == 0 {
a = nr
} else {
a = arch
switch f.k {
case 0:
a = c.nr
case 4:
a = c.arch
case 16:
a = c.arg0
default:
t.Fatalf("load from unmodelled seccomp_data offset %d", f.k)
}
pc++
case 0x15: // BPF_JMP|BPF_JEQ|BPF_K
+1 -1
View File
@@ -185,7 +185,7 @@ var table = map[string]Lang{
// in, and tsserver reads both. Leaving them out meant a package that
// spells its entry point .mjs — an ordinary modern one — looked to
// Speaks() like a repository with no JavaScript in it at all.
Exts: []string{".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"},
Exts: []string{".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"},
Cache: "npm",
// --ignore-scripts is the whole reason this fetch is allowed: it is npm's
// own switch for "place the tree, run none of its lifecycle hooks". The