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
| Denied | Because |
|---|---|
ptrace | tracing sibling processes, classic debugger-based escape primitive |
mount, umount2, pivot_root, chroot | filesystem namespace tampering |
unshare, setns | joining 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 calls | no network, full stop — namespace isolation already removes any interface, seccomp removes the syscalls too |
io_uring_setup/_enter/_register | a large, historically exploit-prone modern kernel surface |
bpf, perf_event_open | kernel introspection and historically exploitable verifiers |
userfaultfd | userspace page-fault handling, used in real-world kernel race-condition exploits for heap grooming |
reboot, kexec_load, init_module, delete_module | obviously host-level operations |
keyctl, add_key, request_key | kernel keyring, unrelated attack surface with no legitimate use here |
personality, modify_ldt, iopl, ioperm | ASLR 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
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.
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.