mirror of
https://github.com/hanzoai/lsp.git
synced 2026-09-30 22:52:48 +00:00
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]>
676 lines
28 KiB
Go
676 lines
28 KiB
Go
//go:build linux
|
|
|
|
package jail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"unsafe"
|
|
)
|
|
|
|
// ── how the jail is entered ──────────────────────────────────────────────────
|
|
//
|
|
// The daemon RE-EXECS ITSELF. command() builds an *exec.Cmd that runs this same
|
|
// binary with the control environment set and SysProcAttr asking the kernel for
|
|
// new user/mount/pid/ipc/uts namespaces. main() calls Child() first thing, so the
|
|
// re-exec'd process takes the child path below, builds its jail, and execs the
|
|
// real program as its last act.
|
|
//
|
|
// WHY NOT nsjail: nsjail is incompatible with gVisor. It calls
|
|
// prctl(PR_SET_SECUREBITS) unconditionally during uid setup, which the gVisor
|
|
// sentry does not implement, so the child never launches; it also creates a
|
|
// cgroup namespace and a netlink-configured net namespace that gVisor rejects,
|
|
// and no flag turns those off. Proven on-target. A small auditable Go component
|
|
// avoids all three and keeps this repo dependency-free.
|
|
//
|
|
// WHY NO NET NAMESPACE: the same gVisor incompatibility. The network boundary is
|
|
// therefore at the SYSCALL layer — seccomp denies socket/socketpair and the
|
|
// io_uring family, so the served process can obtain no socket fd at all. That is
|
|
// the property an empty net namespace would give, achieved where gVisor allows.
|
|
|
|
// self is this binary, re-exec'd as the jail child.
|
|
var self, selfErr = os.Executable()
|
|
|
|
// Supported reports whether a jail can be built on this platform and binary.
|
|
// On Linux it is true whenever the executable path resolved: whether the HOST
|
|
// can actually create the namespaces is what Probe answers.
|
|
func Supported() bool { return selfErr == nil && self != "" }
|
|
|
|
// command builds the re-exec. The child's environment is the control variables
|
|
// plus the clean environment the caller built for the program; this process's
|
|
// own environment — which holds the service key — is not passed.
|
|
func command(ctx context.Context, p phase, s Spec) *exec.Cmd {
|
|
c := exec.CommandContext(ctx, self)
|
|
c.Env = append(control(p, s), s.Env...)
|
|
c.SysProcAttr = &syscall.SysProcAttr{
|
|
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNS |
|
|
syscall.CLONE_NEWPID | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS,
|
|
UidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getuid(), Size: 1}},
|
|
GidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getgid(), Size: 1}},
|
|
GidMappingsEnableSetgroups: false,
|
|
// No Pdeathsig: in Go it fires when the CREATING OS THREAD exits, not the
|
|
// process, and the runtime retires threads freely — it would kill warm
|
|
// language servers at random. The child is PID 1 of a new PID namespace,
|
|
// so killing it already takes everything it forked with it; that is the
|
|
// reaping guarantee, and it is the kernel's rather than a signal's.
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Child runs the jail-child path and never returns, if this process IS a jail
|
|
// child or the canary. In the daemon process it does nothing. main() calls it
|
|
// first, before any server setup, so the child stays minimal.
|
|
func Child() {
|
|
// The canary, which a jail EXECS. It reports whether the kernel would hand it
|
|
// a socket and exits. Because it is reached by exec — the same way a language
|
|
// server is reached — a jail that builds perfectly and then cannot exec fails
|
|
// the self-test, instead of passing it from inside the process that built it.
|
|
if len(os.Args) == 2 && os.Args[1] == canary {
|
|
if fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0); err != nil {
|
|
fmt.Println(canaryDeny, err)
|
|
} else {
|
|
syscall.Close(fd)
|
|
fmt.Println(canaryOK)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
p := phase(os.Getenv(envPhase))
|
|
if p != fetch && p != serve {
|
|
return
|
|
}
|
|
if err := run(p); err != nil {
|
|
fmt.Fprintln(os.Stderr, "jail:", err)
|
|
}
|
|
os.Exit(setupFailed) // run() execs on success; arriving here means it did not
|
|
}
|
|
|
|
// run executes inside the new namespaces, as root within its own user namespace
|
|
// and as the daemon's non-root uid outside it. It builds the chroot, applies the
|
|
// limits, installs the filter, and execs.
|
|
func run(p phase) error {
|
|
// LOCK THE THREAD FIRST. no_new_privs and PR_SET_SECCOMP apply to the
|
|
// CALLING THREAD, and syscall.Exec execs the calling thread. If the Go
|
|
// scheduler moved this goroutine between installing the filter and exec'ing,
|
|
// the exec'd program would inherit NO filter and the sandbox would silently
|
|
// not exist. This is the fix over the code-exec original, where the sequence
|
|
// is short enough that it has always held in practice — "in practice" not
|
|
// being a security boundary.
|
|
runtime.LockOSThread()
|
|
|
|
root := os.Getenv(envRoot)
|
|
work := os.Getenv(envWork)
|
|
if root == "" || work == "" {
|
|
return errors.New("missing root or work")
|
|
}
|
|
argv := split(os.Getenv(envArgv))
|
|
if len(argv) == 0 {
|
|
return errors.New("empty argv")
|
|
}
|
|
|
|
// (a) A private mount tree, so nothing built here propagates back out.
|
|
if err := syscall.Mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, ""); err != nil {
|
|
return fmt.Errorf("make-rprivate: %w", err)
|
|
}
|
|
// A tmpfs as the chroot base. It holds only mountpoints, so it can be tiny —
|
|
// and it must be, since a tmpfs is charged to the pod's memory.
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir root: %w", err)
|
|
}
|
|
if err := syscall.Mount("tmpfs", root, "tmpfs", 0, "mode=0755,size=8m"); err != nil {
|
|
return fmt.Errorf("mount root: %w", err)
|
|
}
|
|
|
|
// (b) Fill it, in the order plan() put things in.
|
|
for _, m := range plan(p, work, split(os.Getenv(envRead)),
|
|
split(os.Getenv(envWrite)), num(envTmp, 256)) {
|
|
if err := m.make(root); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// (c) Enter it. The old root is gone; work is reachable at the same path it
|
|
// had outside.
|
|
if err := syscall.Chroot(root); err != nil {
|
|
return fmt.Errorf("chroot: %w", err)
|
|
}
|
|
dir := os.Getenv(envDir)
|
|
if dir == "" {
|
|
dir = work
|
|
}
|
|
if err := syscall.Chdir(dir); err != nil {
|
|
return fmt.Errorf("chdir %s: %w", dir, err)
|
|
}
|
|
|
|
// (d) Ceilings, then no_new_privs, then the filter. Order matters:
|
|
// SECCOMP_MODE_FILTER requires no_new_privs for a non-root caller.
|
|
if err := limits(); err != nil {
|
|
return err
|
|
}
|
|
if _, _, e := syscall.Syscall6(syscall.SYS_PRCTL, prSetNoNewPrivs, 1, 0, 0, 0, 0); e != 0 {
|
|
return fmt.Errorf("no_new_privs: %w", e)
|
|
}
|
|
if err := seccomp(p); err != nil {
|
|
return err
|
|
}
|
|
|
|
// (e) Become the program. There is no other ending — the canary is a program
|
|
// like any other, so the exec is on the path the self-test proves.
|
|
if err := syscall.Exec(argv[0], argv, clean()); err != nil {
|
|
return fmt.Errorf("exec %s: %w", argv[0], err)
|
|
}
|
|
return nil // unreachable
|
|
}
|
|
|
|
// ── what the jail's filesystem is ────────────────────────────────────────────
|
|
|
|
// mount is one line of that filesystem, as a value: what appears at path inside
|
|
// the jail, and whether the program may write it. fs names a filesystem to
|
|
// create fresh; empty fs means a bind of src.
|
|
//
|
|
// path is ALWAYS the source's own absolute path. Same-path is not a convenience:
|
|
// a language server reports every answer as a file: URI of a path it was given,
|
|
// so a jail that renamed /roots/x to /work would hand the caller paths that name
|
|
// nothing — or would need a translation table, which is a second place for the
|
|
// two sides to disagree. There is nothing to translate here.
|
|
//
|
|
// write is the ONE truth about read-only-ness, for a bind and a fresh filesystem
|
|
// alike: MS_RDONLY is derived from it rather than carried beside it, so the two
|
|
// cannot disagree.
|
|
type mount struct {
|
|
src string // the host path to bind; empty when fs is set
|
|
fs string // "tmpfs" | "proc": a fresh filesystem; empty ⇒ a bind of src
|
|
opt string // mount options for fs
|
|
flag uintptr // mount flags for fs, minus MS_RDONLY — see write
|
|
path string // absolute, and the same inside the jail as outside
|
|
write bool // may the jailed program write it
|
|
must bool // a missing source is an error rather than a skip
|
|
}
|
|
|
|
// plan is the whole filesystem the jail will have, ORDERED SHALLOWEST PATH
|
|
// FIRST — and that ordering is the reason this is a value rather than a run of
|
|
// mount calls.
|
|
//
|
|
// A mount whose path is an ANCESTOR of an earlier one covers it. Nothing fails
|
|
// at the time: the earlier mount is still there, and simply cannot be seen. The
|
|
// child fails much later, on chdir or exec, with ENOENT on a path that plainly
|
|
// exists outside the jail. That was this daemon's boot self-test — the writable
|
|
// /tmp was mounted AFTER the binds, so every bind under /tmp was placed and then
|
|
// erased, and /tmp is where the staging directory lives. The pod reported `exit
|
|
// status 126` and refused every request, correctly, for a filesystem bug.
|
|
//
|
|
// Sorting by depth makes an ancestor-after-descendant order unrepresentable, so
|
|
// the rule is enforced rather than remembered. TestThePlanHidesNothing asserts
|
|
// it directly, needs no privileges, and would have caught this before it shipped.
|
|
func plan(p phase, work string, read, write []string, tmp uint64) []mount {
|
|
// The two filesystems the jail brings with it, shallowest of all so
|
|
// everything binds ON them.
|
|
ms := []mount{
|
|
// The jail's own writable /tmp: the ONLY thing a served process can
|
|
// write, and it dies with the process. Build caches live here for
|
|
// exactly that reason. nosuid/nodev because a scratch directory has no
|
|
// business holding a setuid binary or a device node; NOT noexec, since
|
|
// the go command compiles and runs out of $TMPDIR.
|
|
{fs: "tmpfs", path: "/tmp", write: true,
|
|
opt: fmt.Sprintf("mode=1777,size=%dm", tmp),
|
|
flag: syscall.MS_NOSUID | syscall.MS_NODEV},
|
|
|
|
// A PRIVATE procfs, and it is not a hole in the jail — it is this jail's
|
|
// own, mounted inside this jail's PID namespace, so it shows the jailed
|
|
// process and its children and NOTHING of the host. Read-only, nosuid,
|
|
// nodev, noexec.
|
|
//
|
|
// It is here because a Go program cannot start without it. os.Executable
|
|
// reads /proc/self/exe, and x/telemetry calls it on init, so gopls died
|
|
// at startup with
|
|
//
|
|
// failed to start telemetry sidecar: os.Executable: readlink
|
|
// /proc/self/exe: no such file or directory
|
|
// gopls cannot access its persistent index: can't hash gopls executable
|
|
//
|
|
// and the daemon reported only "server stopped". GOTELEMETRY=off does not
|
|
// prevent it: x/telemetry reads its mode file, not the environment. Every
|
|
// language server this daemon runs is a Go, Node or Python binary that
|
|
// expects a /proc, so giving the jail its own is the general answer and
|
|
// removes the reason GOROOT had to be pinned by hand.
|
|
{fs: "proc", path: "/proc",
|
|
flag: syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC},
|
|
}
|
|
|
|
// The device nodes every toolchain assumes exist. They are BOUND from the
|
|
// host rather than created, because a user namespace cannot mknod a device —
|
|
// that is the one thing CAP_MKNOD inside a userns does not buy.
|
|
//
|
|
// They are writable in BOTH phases, and that is not a hole in the serve
|
|
// phase: none of them holds state. Writes to null and zero are discarded,
|
|
// full returns ENOSPC, and a write to random or urandom cannot credit
|
|
// entropy without CAP_SYS_ADMIN in the initial namespace. Read-only would
|
|
// break them for their actual purpose — `open("/dev/null", O_WRONLY)` on a
|
|
// read-only mount is EROFS, and Go's os/exec opens exactly that for every
|
|
// subprocess with no stdin, so `go list` could not run at all.
|
|
//
|
|
// REQUIRED, not skipped when absent: a jail without /dev/null fails later and
|
|
// obscurely, which is the same shape of bug as a mount that hides another.
|
|
for _, dev := range []string{
|
|
"/dev/null", "/dev/zero", "/dev/full", "/dev/random", "/dev/urandom",
|
|
} {
|
|
ms = append(ms, mount{src: dev, path: dev, write: true, must: true})
|
|
}
|
|
|
|
// Serve binds EVERYTHING read-only, including the working tree: a root is
|
|
// immutable and a language server has no business writing to one. Fetch binds
|
|
// Work and Write read-write, because populating a dependency cache is
|
|
// precisely what it is for. That is the ONE difference, and it is this line.
|
|
writable := p == fetch
|
|
for _, src := range read {
|
|
ms = append(ms, mount{src: src, path: src})
|
|
}
|
|
for _, src := range write {
|
|
ms = append(ms, mount{src: src, path: src, write: writable})
|
|
}
|
|
// The working tree is the one path that is NOT optional. The read list is a
|
|
// superset across image layouts — a host without /lib64 must still jail — but
|
|
// a jail whose working tree is missing has nothing to chdir into, and saying
|
|
// so HERE names the cause instead of leaving a chdir to report the symptom.
|
|
ms = append(ms, mount{src: work, path: work, write: writable, must: true})
|
|
|
|
// ONE MOUNT PER PATH, and the first claim wins. Two mounts at one path are
|
|
// the same hiding as an ancestor mounted late, and the order above decides it
|
|
// the safe way: the jail's own /tmp is first, so a caller that asks to bind
|
|
// /tmp gets the private tmpfs that dies with the process rather than the
|
|
// host's directory. A boundary must not be overridable by an argument.
|
|
seen := make(map[string]bool, len(ms))
|
|
out := ms[:0]
|
|
for _, m := range ms {
|
|
if m.path == "" {
|
|
continue // an unconfigured cache is spelled ""
|
|
}
|
|
m.path = filepath.Clean(m.path)
|
|
if seen[m.path] {
|
|
continue
|
|
}
|
|
seen[m.path] = true
|
|
out = append(out, m)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return depth(out[i].path) < depth(out[j].path) })
|
|
return out
|
|
}
|
|
|
|
// depth is how many separators a cleaned absolute path has. An ancestor always
|
|
// has strictly fewer than its descendants, which is all the sort needs.
|
|
func depth(p string) int { return strings.Count(p, string(filepath.Separator)) }
|
|
|
|
// make performs one line of the plan, inside the staging root.
|
|
func (m mount) make(root string) error {
|
|
dst := filepath.Join(root, m.path)
|
|
if m.fs != "" {
|
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", m.path, err)
|
|
}
|
|
flag := m.flag
|
|
if !m.write {
|
|
flag |= syscall.MS_RDONLY
|
|
}
|
|
if err := syscall.Mount(m.fs, dst, m.fs, flag, m.opt); err != nil {
|
|
return fmt.Errorf("mount %s %s: %w", m.fs, m.path, err)
|
|
}
|
|
return nil
|
|
}
|
|
fi, err := os.Stat(m.src)
|
|
if err != nil {
|
|
if m.must {
|
|
return fmt.Errorf("bind %s: %w", m.src, err)
|
|
}
|
|
return nil
|
|
}
|
|
if fi.IsDir() {
|
|
err = os.MkdirAll(dst, 0o755)
|
|
} else {
|
|
if err = os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
|
var f *os.File
|
|
if f, err = os.OpenFile(dst, os.O_CREATE, 0o644); err == nil {
|
|
err = f.Close()
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("bind target %s: %w", m.src, err)
|
|
}
|
|
if err := syscall.Mount(m.src, dst, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
|
|
return fmt.Errorf("bind %s: %w", m.src, err)
|
|
}
|
|
if m.write {
|
|
return nil
|
|
}
|
|
// A bind acquires read-only only on a second, remounting call.
|
|
if err := syscall.Mount("", dst, "", syscall.MS_BIND|syscall.MS_REC|syscall.MS_REMOUNT|syscall.MS_RDONLY, ""); err != nil {
|
|
return fmt.Errorf("seal %s: %w", m.src, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// limits applies the ceilings the parent chose. A zero is not applied — that is
|
|
// how a long-lived server declines RLIMIT_CPU, which would otherwise kill it
|
|
// after a fixed amount of work.
|
|
func limits() error {
|
|
set := func(res int, v uint64, name string) error {
|
|
if v == 0 {
|
|
return nil
|
|
}
|
|
if err := syscall.Setrlimit(res, &syscall.Rlimit{Cur: v, Max: v}); err != nil {
|
|
return fmt.Errorf("rlimit %s: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := set(syscall.RLIMIT_AS, num(envAddr, 0)*1024*1024, "as"); err != nil {
|
|
return err
|
|
}
|
|
if err := set(syscall.RLIMIT_CPU, num(envCPU, 0), "cpu"); err != nil {
|
|
return err
|
|
}
|
|
if err := set(syscall.RLIMIT_FSIZE, num(envFsize, 0)*1024*1024, "fsize"); err != nil {
|
|
return err
|
|
}
|
|
if err := set(syscall.RLIMIT_NOFILE, num(envNofile, 0), "nofile"); err != nil {
|
|
return err
|
|
}
|
|
return set(rlimitNproc, num(envNproc, 0), "nproc")
|
|
}
|
|
|
|
// clean is the environment handed to the jailed program: this child's own
|
|
// environment minus every control variable, so none of them reaches it.
|
|
func clean() []string {
|
|
out := make([]string, 0, len(os.Environ()))
|
|
for _, kv := range os.Environ() {
|
|
if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, envPrefix) {
|
|
continue
|
|
}
|
|
out = append(out, kv)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func split(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(s, unit)
|
|
}
|
|
|
|
func num(key string, def uint64) uint64 {
|
|
if v, err := strconv.ParseUint(os.Getenv(key), 10, 64); err == nil {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// ── the boot self-test ───────────────────────────────────────────────────────
|
|
|
|
// ErrHost reports that this host cannot create the namespaces a jail needs: the
|
|
// child never started. Every OTHER Probe failure means the child DID start and
|
|
// the jail itself is wrong, and the two must never be read as one — a caller
|
|
// that tolerates the first would otherwise tolerate the second and certify
|
|
// nothing. On the target, both are fatal; only a test may skip on this one.
|
|
var ErrHost = errors.New("jail: this host cannot create namespaces")
|
|
|
|
// Probe builds a real jail in each phase, EXECS the canary in it, and makes it
|
|
// say whether a socket was obtainable. It is the boot self-test, and it is the
|
|
// reason /readyz can be trusted: a host where the namespaces cannot be created,
|
|
// where a mount is masked, where the exec cannot happen or where the filter did
|
|
// not take fails here, and the daemon then refuses every request rather than
|
|
// serving untrusted bytes next to a network.
|
|
//
|
|
// root is the staging base, and it is the ONLY path this takes. The canary's
|
|
// working tree is the directory the canary lives in, so there is no second
|
|
// directory to get into the wrong relationship with the first — which is exactly
|
|
// the mistake that made this self-test fail on every host it ran on.
|
|
func Probe(ctx context.Context, root string) error {
|
|
if !Supported() {
|
|
return errors.New("jail: unsupported on this build")
|
|
}
|
|
for _, c := range []struct {
|
|
p phase
|
|
want string
|
|
}{
|
|
{serve, canaryDeny},
|
|
{fetch, canaryOK},
|
|
} {
|
|
out, err := command(ctx, c.p, Spec{
|
|
Root: root,
|
|
Work: filepath.Dir(self),
|
|
Argv: []string{self, canary},
|
|
// GENEROUS ON PURPOSE. A ceiling that stops the canary starting takes
|
|
// the daemon out of service without any boundary having failed, which
|
|
// is the worst answer a self-test can give. RLIMIT_NPROC especially:
|
|
// it is per-UID, so the jailed process shares its budget with the
|
|
// daemon's own threads — measured on the target, 32 leaves a Go
|
|
// program unable to start a third thread, and that reads exactly like
|
|
// a broken jail. Ceilings are proven by the phases that use them.
|
|
Limits: Limits{AddrMiB: 4096, CPU: 10, Nofile: 256, Nproc: 256, TmpMiB: 16},
|
|
}).Output()
|
|
if err != nil {
|
|
var ee *exec.ExitError
|
|
if !errors.As(err, &ee) {
|
|
return fmt.Errorf("%w: %v", ErrHost, err)
|
|
}
|
|
// THE CHILD ALREADY SAID WHY. run() writes "jail: <reason>" to stderr
|
|
// and exits 126; Output() parks that on ExitError.Stderr, and this
|
|
// function used to drop it and report only "exit status 126".
|
|
//
|
|
// That number is not a diagnosis. It is the same code for a missing
|
|
// mount, an unmappable uid, a chroot that failed and a chdir into a
|
|
// masked path — and it was all an operator got for a pod that would
|
|
// not go ready. Never report an exit status without the words beside it.
|
|
return fmt.Errorf("jail: %s canary did not run: %w: %s",
|
|
c.p, err, strings.TrimSpace(string(ee.Stderr)))
|
|
}
|
|
got := strings.TrimSpace(string(out))
|
|
if !strings.HasPrefix(got, c.want) {
|
|
return fmt.Errorf("jail: %s canary reported %q, want %q — the phase boundary is not in force",
|
|
c.p, got, c.want)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── seccomp-bpf ──────────────────────────────────────────────────────────────
|
|
//
|
|
// A classic BPF program installed with prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER).
|
|
// It:
|
|
// - KILLs any audit arch that is not x86_64, which stops the i386 ABI whose
|
|
// syscall numbers differ entirely and would sail past a numeric filter;
|
|
// - KILLs any syscall carrying the x32 bit (0x40000000), since x32 shares
|
|
// 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.
|
|
|
|
// escape is denied in BOTH phases. Neither a module download nor a language
|
|
// server has any use for these, and each is a documented container-escape or
|
|
// sandbox-bypass primitive.
|
|
var escape = []uint32{
|
|
// io_uring — the OTHER way to get a socket. Its ops (IORING_OP_SOCKET,
|
|
// CONNECT, SEND, OPENAT …) do network and filesystem work WITHOUT issuing
|
|
// socket()/connect(), so a socket-only deny would not be the whole boundary.
|
|
// Denying the ring's setup means no ring exists to issue them from.
|
|
sysIoUringSetup, sysIoUringEnter, sysIoUringRegister,
|
|
// kernel keyring (container-escape CVEs)
|
|
syscall.SYS_ADD_KEY, syscall.SYS_REQUEST_KEY, syscall.SYS_KEYCTL,
|
|
// cross-process memory and ptrace
|
|
syscall.SYS_PTRACE, sysProcessVMReadv, sysProcessVMWritev,
|
|
// eBPF
|
|
sysBPF,
|
|
// mount / pivot / chroot: the program is already chrooted into a minimal
|
|
// root, and a second chroot is an escape primitive, never a need.
|
|
syscall.SYS_MOUNT, syscall.SYS_UMOUNT2, syscall.SYS_PIVOT_ROOT, syscall.SYS_CHROOT,
|
|
sysMoveMount, sysOpenTree, sysFsopen, sysFsconfig, sysFsmount, sysFspick,
|
|
// new namespaces from inside
|
|
syscall.SYS_UNSHARE, sysSetns,
|
|
// kernel code load
|
|
syscall.SYS_INIT_MODULE, sysFinitModule, syscall.SYS_DELETE_MODULE,
|
|
syscall.SYS_KEXEC_LOAD, sysKexecFileLoad,
|
|
}
|
|
|
|
// 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"
|
|
// has a single answer that can be read in full.
|
|
func denied(p phase) []uint32 {
|
|
if p == fetch {
|
|
return escape
|
|
}
|
|
return append(append([]uint32{}, network...), escape...)
|
|
}
|
|
|
|
// x86_64 numbers absent from Go's syscall package. These are a STABLE kernel ABI
|
|
// on amd64 — the target arch, and the only one the image is built for — so
|
|
// hardcoding them is correct and dependency-free.
|
|
const (
|
|
sysProcessVMReadv = 310
|
|
sysProcessVMWritev = 311
|
|
sysBPF = 321
|
|
sysSetns = 308
|
|
sysFsopen = 430
|
|
sysFsconfig = 431
|
|
sysFsmount = 432
|
|
sysFspick = 433
|
|
sysOpenTree = 428
|
|
sysMoveMount = 429
|
|
sysFinitModule = 313
|
|
sysKexecFileLoad = 320
|
|
sysIoUringSetup = 425
|
|
sysIoUringEnter = 426
|
|
sysIoUringRegister = 427
|
|
|
|
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
|
|
// structural test share ONE definition of each security-critical number.
|
|
const (
|
|
retAllow = 0x7FFF0000 // SECCOMP_RET_ALLOW
|
|
retErrno = 0x00050000 | 1 // SECCOMP_RET_ERRNO | EPERM
|
|
retKill = 0x80000000 // SECCOMP_RET_KILL_PROCESS
|
|
auditArchAMD64 = 0xC000003E // AUDIT_ARCH_X86_64
|
|
x32Bit = 0x40000000 // __X32_SYSCALL_BIT, set on every x32 number
|
|
)
|
|
|
|
// filter is the cBPF program for p. It is built here rather than inline so its
|
|
// jump arithmetic — where an off-by-one would silently ALLOW a denied syscall —
|
|
// is unit-tested by interpreting the program, with no need to enter seccomp mode.
|
|
func filter(p phase) []insn {
|
|
const (
|
|
ld = 0x00 | 0x00 | 0x20 // BPF_LD|BPF_W|BPF_ABS
|
|
jeq = 0x05 | 0x10 | 0x00 // BPF_JMP|BPF_JEQ|BPF_K
|
|
jset = 0x05 | 0x40 | 0x00 // BPF_JMP|BPF_JSET|BPF_K
|
|
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{
|
|
// 1. Wrong arch ⇒ KILL. This is what stops the i386 ABI.
|
|
{code: ld, k: atArc},
|
|
{code: jeq, jt: 1, k: auditArchAMD64},
|
|
{code: ret, k: retKill},
|
|
// 2. Load the number, then refuse the x32 ABI wholesale: it shares this
|
|
// audit arch but ORs 0x40000000 into every number, so the numeric compares
|
|
// below would miss its socket. No toolchain on an amd64 image uses x32.
|
|
{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},
|
|
}
|
|
// 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})
|
|
}
|
|
// 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})
|
|
}
|
|
|
|
// insn mirrors struct sock_filter { u16 code; u8 jt; u8 jf; u32 k; }.
|
|
type insn struct {
|
|
code uint16
|
|
jt uint8
|
|
jf uint8
|
|
k uint32
|
|
}
|
|
|
|
// program mirrors struct sock_fprog { u16 len; sock_filter *filter; }.
|
|
type program struct {
|
|
len uint16
|
|
_ [6]byte // pad to the pointer's 8-byte alignment on amd64
|
|
f *insn
|
|
}
|
|
|
|
// seccomp installs filter(p) on the calling thread — which run() has locked, and
|
|
// which is the thread that will exec.
|
|
func seccomp(p phase) error {
|
|
const (
|
|
prSetSeccomp = 22 // PR_SET_SECCOMP
|
|
modeFilter = 2 // SECCOMP_MODE_FILTER
|
|
)
|
|
prog := filter(p)
|
|
fp := program{len: uint16(len(prog)), f: &prog[0]}
|
|
if _, _, e := syscall.Syscall6(syscall.SYS_PRCTL, prSetSeccomp, modeFilter,
|
|
uintptr(unsafe.Pointer(&fp)), 0, 0, 0); e != 0 {
|
|
return fmt.Errorf("prctl(PR_SET_SECCOMP): %w", e)
|
|
}
|
|
return nil
|
|
}
|