sandbin

Security

A threat model stated plainly, what's actually denied and why, and every real bug this project's own development surfaced — not a curated highlight reel.

Threat model

sandbin hardens against careless or mildly malicious code: infinite loops, fork bombs, memory bombs, attempts to read the host filesystem or reach the network, attempts to escalate via a nested namespace. It does not defend against an attacker who already has a kernel exploit. Guest and host share one kernel; seccomp shrinks the reachable syscall surface, it does not add a second kernel between them the way a VM-based sandbox (Firecracker, gVisor) would.

That's a deliberate trade, not an oversight: skipping a second kernel is exactly what buys the ~20 ms cold start. A workload that needs to withstand a kernel 0-day belongs behind a VM boundary in addition to this, not instead of it.

What's denied, and why

DeniedBecause
ptracetracing sibling processes, classic debugger-based escape primitive
mount, umount2, pivot_root, chrootfilesystem namespace tampering
unshare, setnsjoining or creating namespaces after the sandbox is already set up
clone with CLONE_NEWUSER/etc.nested "privileged inside" namespace from an unprivileged process — see Architecture for the exact mechanism
all socket()-family callsno network, full stop — namespace isolation already removes any interface, seccomp removes the syscalls too
io_uring_setup/_enter/_registera large, historically exploit-prone modern kernel surface
bpf, perf_event_openkernel introspection and historically exploitable verifiers
userfaultfduserspace page-fault handling, used in real-world kernel race-condition exploits for heap grooming
reboot, kexec_load, init_module, delete_moduleobviously host-level operations
keyctl, add_key, request_keykernel keyring, unrelated attack surface with no legitimate use here
personality, modify_ldt, iopl, iopermASLR manipulation and raw I/O port access, both exploit-primitive-adjacent

Everything above is denied by omission — the filter is an allowlist of 146 syscalls a Python, Bash, Node or C program legitimately needs, not a blocklist of these specific names. Nothing on this list, or anything not on the allow side, gets a second thought.

Known limitations

Memory spikes can occasionally slip through

cgroup v2's memory.max attempts reclaim before escalating to an OOM kill. A spike large enough to exceed the limit but brief enough to finish and free itself before that escalation completes can legitimately avoid ever being killed. This is a kernel characteristic, not a sandbin gap, and it applies equally to every language — a sustained excess (allocate, hold, keep running) is always caught. Found via a real test: the same C allocation caught reliably with a trailing sleep(3), and slipped through without it.

What actually changes on the public internet

Anonymous, unauthenticated code execution is the deliberate feature here, not an oversight — the sandbox is the entire security model for that path. Three things a real deployment still needs to handle itself: this server speaks plain HTTP, so it needs a TLS-terminating reverse proxy in front of it; rate limiting and maxPerKey key off the real socket address, which isn't safe behind a proxy unless that proxy's own real-IP header is both trusted and parsed — deliberately not done here, since blindly trusting a client-suppliable header would just trade one spoofing vector for another; and a finished run's permalink is unauthenticated by design — unguessable, not secret, so nothing typed into code or stdin should be either.

Bugs found building this

Every one of these was found by actually running adversarial code and checking observable evidence — cgroup counters, host-side file checks, wall-clock timing, the specific errno a syscall returned — not by reading the source and reasoning it was probably fine.

Cgroup PID assignment race

An early optimization moved a process into its cgroup from the supervisor, asynchronously, after spawn() returned. There was no ordering guarantee against the child's own exec sequence — the guest could start running, and even fork descendants, before ever landing in the cgroup meant to constrain it, silently disabling every resource limit for that run. Fixed by restoring self-migration as the guaranteed-first line the child executes, with an explicit sentinel exit code on failure instead of a swallowed error.

The supervisor could hang

A stuck CI run surfaced a real gap: if a descendant process kept the guest's stdio pipes open, the supervisor's promise never resolved, no matter what the wall-clock deadline said. run() now settles within wallClockMs + 2s unconditionally, backstopped by a hard timer independent of whether the pipes ever actually close.

Two separate CI-environment cgroup failures

Getting the adversarial suite green on a real GitHub Actions runner, rather than just locally, surfaced Ubuntu 24.04's AppArmor restriction on unprivileged user namespaces, and separately a cgroup hierarchy where the job's own cgroup already held resident processes — violating cgroup v2's rule against enabling delegation on an occupied cgroup. See Architecture for how the fix generalizes instead of special-casing one CI provider.

umask and getsockopt/getsockname

Adding Node and C surfaced both in one session: a missing benign syscall (umask) silently broke file permissions on a compiled binary three layers downstream, and two other denied-by-omission syscalls silently broke Node's own stdout detection rather than erroring visibly. Full details on Languages and Architecture.

setuid/setgid/setresuid/setresgid

Adding Go surfaced this one by accident, chasing what turned out to be a different, unresolved problem in rustc specifically (see ROADMAP.md for that full trail). A minimal, isolated reproduction — os.posix_spawn(..., resetids=True) — failed with a bare EPERM before ever reaching the target program: POSIX_SPAWN_RESETIDS makes glibc call setuid() in the child unconditionally, even when the real and effective uid are already identical and the call is a complete no-op. None of the setuid family was on the allowlist — an oversight, the same category as umask above, not a deliberate exclusion. Added back with no real cost: an unprivileged process calling setuid() on its own uid changes nothing the kernel's own permission check wouldn't already block if it tried to change anything else.

The frontend hid correct behavior as a bug

Actually clicking through the UI in a browser — not just trusting the API-level test suite — found that a run finishing before the WebSocket handshake completes (routine, given ~20 ms cold starts) correctly replays a single buffered result with no live chunk events, exactly as designed; the frontend simply never rendered that buffered output. The API was right. The UI built on top of a correct API was still wrong.

A full control-plane audit

Everything above came from building the sandbox. A separate pass specifically targeted sandbox.mjs, policy.c and server.mjs for sandbox escapes, TOCTOU, leaks, and what changes once this is reachable from the public internet — the same evidence-not-narration rule, aimed at the parts of the codebase most of the bugs above hadn't touched. Full trail, including everything investigated and found already correct, in ROADMAP.md.

Unauthenticated RCE via a limits field

limits.openFiles was interpolated straight into a shell script (ulimit -n ${lim.openFiles}) with no validation and no escaping. A single POST /runs with limits: { openFiles: "64; touch /tmp/x #" } ran the injected command as the sandbin process itself, before the sandbox even started. Confirmed with a real file appearing on the host from one unauthenticated request. Fixed by clamping every field in limits to a bounded integer before it reaches anywhere — a cgroup write, a bwrap argument, or a shell script — with the coercion re-asserted again at the interpolation site itself, not just trusted from upstream.

Path traversal via a request header

X-Sandbin-Key reached apiKeys.load() unvalidated. The two routes that read an id from a URL path happened to be safe already, but only because their [^/]+ route regex can't capture a literal / — an accident of routing, not a check — and a header isn't a URL segment, so the same accident didn't apply to it. X-Sandbin-Key: ../../elsewhere/canary resolved straight to a file outside the key store, confirmed with a real file whose fabricated quota then granted more than the real anonymous rate limit end to end. Fixed with a strict format check, matching exactly what this app itself generates, applied to every route that accepts one of these ids.

Five resource leaks, each reproduced before being called a bug

An unbounded rate-limiter Map keyed by attacker-rotatable IPs (confirmed with 200,000 synthetic ids); API keys with no expiry at all (a key backdated five years still loaded at full quota); a cgroup and the guest's own submitted source left on disk whenever spawnInSandbox threw partway through setup; a live stats interval that could outlive the result it was measuring, provable from JavaScript's own microtask-ordering guarantees rather than a flaky end-to-end reproduction; and the Go warm-up cache's scratch directory, never removed — 254 confirmed leaked on the machine this was found on. All five fixed with try/finally and a periodic sweep(), and all five have a regression test that reproduces the exact leak.

An API key's own TTL wasn't checked at the point of use

Fixing the leak above added a 90-day TTL and an hourly sweep() to the key store — but load() itself never checked a key's age; it just trusted that sweep() had already deleted anything expired. A key just past its TTL kept working, at full quota, for up to an hour after expiring: the gap between a periodic cleanup and the record's own timestamp actually being enforced. Confirmed by backdating a key and loading it with sweep() never once called. Fixed to check inline, matching the pattern the permalink store already used correctly.

A timing side-channel in the metrics password check

/metrics's optional Basic Auth compared the decoded credentials with plain ===, which returns as soon as it hits the first mismatched byte — a real, if slow, remote signal for guessing the password one byte at a time. Fixed with crypto.timingSafeEqual, checking the username and password unconditionally rather than short-circuiting on the username alone.

A CSRF-shaped path into POST /runs

The request body was parsed as JSON regardless of what Content-Type the request actually declared. application/json forces a browser onto the CORS preflight path — which this server was already safe against, since it never answers OPTIONS — but text/plain is CORS-safelisted, no preflight required, and nothing stopped a JSON-shaped body from arriving under that header instead. Confirmed directly: a request with Content-Type: text/plain and a foreign Origin was accepted and queued exactly like a same-origin call. Any page a visitor had open could have silently submitted runs under that visitor's own IP. Fixed by requiring application/json before parsing.

A review of that same week's own diff

Everything above, checked again: a ten-angle pass over every commit from the RCE fix through the CSRF fix (~1300 lines), specifically to catch what a security-focused read might have missed on its own territory. Full trail in ROADMAP.md.

The CSRF fix above had its own bypass of the body-size cap

readJsonBody() rejected a bad Content-Type before the listener enforcing the 2 MB body cap was ever attached — so for exactly the request shape the fix above targets, there was no size limit at all. Confirmed with a 200 MB body under Content-Type: text/plain fully absorbed in 93 ms. The first attempt at a fix (req.destroy() on mismatch) closed that but broke the client's ability to ever receive the 400, since destroying the request also tears down the socket the response writes to. Landed on: the size cap always applies regardless of content type, and the connection is only torn down once it's actually exceeded.

A spawn failure could crash the whole process, not just one run

The supervisor never registered child.on('error', ...). Node emits 'error', not 'close', for a child that fails to launch at all — ENOENT, or EMFILE/EAGAIN/ENOMEM under the exact fd/process exhaustion this service is built to run into under concurrent load — and an unhandled 'error' event throws synchronously, taking down every other in-flight run along with it. Confirmed by reproducing the same listener shape against a nonexistent binary.

Two of the path-traversal regression tests above didn't test anything

Both used a payload run through encodeURIComponent, which percent-encodes / — since req.url is never decoded before route matching, the payload always landed as one harmless, nonexistent-file segment whether or not the format check existed. Confirmed by disabling the guard and rerunning the identical test: still green. Rewritten to prove what the guard actually protects instead — a wrong-shaped id pointing at a real file that exists in the store.