mirror of
https://github.com/hanzoai/lsp.git
synced 2026-09-30 22:52:48 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user