Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ The same binary serves both:
and run as the sandbox uid, confined to its home. This packs dozens of isolated CPU
sandboxes into one VM with sub-second per-sandbox cold start.

Host mode needs root + `CAP_SETUID/SETGID/KILL` (the Docker default on HF Jobs) and degrades
to uid-only isolation if Landlock is unavailable. See `src/landlock.rs` for the confinement
model (FS → own home + RO system dirs; no TCP bind; ABI-6 abstract-socket scoping).
Host mode needs root + `CAP_SETUID/SETGID/KILL` (the Docker default on HF Jobs) and refuses to
start if Landlock cannot deliver the documented guarantees (see `SBX_MIN_LANDLOCK_ABI`); pass
`--allow-unconfined` to accept uid-only isolation instead. See `src/landlock.rs` for the
confinement model (FS → own home + RO system dirs and selected `/dev` nodes; no TCP bind;
ABI-6 abstract-socket scoping).

## What it does

Expand Down Expand Up @@ -80,6 +82,7 @@ the home, and it assigns ownership through the resulting descriptor rather than
| `SBX_TOKEN` | **required** | all endpoints except `/health` require this value in the `X-Sandbox-Token` header (constant-time compare); removed from the env before any child process spawns. The server refuses to start without it, unless launched with `--allow-no-auth` (local development only — it is an argv flag, not an env var, so a Job's user-supplied env can never set it) |
| `SBX_IDLE_TIMEOUT` | unset | seconds of inactivity (no authed request, no running process) before clean exit |
| `SBX_COMPAT_HOST_TOKEN` | `1` | host mode: whether the host token is still accepted on per-sandbox routes, for clients that predate per-sandbox tokens. Set to `0` to require scoped tokens |
| `SBX_MIN_LANDLOCK_ABI` | `6` | host mode: minimum Landlock ABI to start with. 4 adds TCP-bind denial, 6 adds abstract-socket scoping — both are part of the documented model, so the default requires them. Lower it to accept a reduced set (`/health` reports what is in force) |

## Security model

Expand Down Expand Up @@ -122,9 +125,6 @@ are known gaps rather than design intent, and are being worked through — treat
boundary between workloads inside **one** trust boundary, and use dedicated mode (one job per
sandbox, a real VM) for mutually distrusting code.

- **Landlock fails open.** If the ruleset cannot be built the sandbox is created anyway with
uid-only isolation, and the client is not told. ABI 1 is accepted, while the documented
guarantees need ABI 4 (no TCP bind) and ABI 6 (abstract-socket scoping).
- **Caller-supplied limits are unclamped**, and `max_mem_mb * 1024 * 1024` is not
`checked_mul`. An invalid `SBX_CAPACITY` becomes `usize::MAX`.
- **The HTTP front end has no read deadlines and no connection cap** (slow-request floods
Expand Down
6 changes: 6 additions & 0 deletions scripts/auth-routes-regression.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ BIN=${BIN:-target/x86_64-unknown-linux-musl/release/sbx-server}
TOKEN=sbx-regression-token
failures=0

# Landlock ABI floor: these checks are about other properties, so accept whatever
# the test kernel offers rather than requiring the production floor (CI and dev
# kernels are often older). scripts/landlock-regression.sh covers the floor.
export SBX_MIN_LANDLOCK_ABI=1


say() { printf '\n=== %s\n' "$1"; }
pass() { printf ' ok %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); }
Expand Down
145 changes: 145 additions & 0 deletions scripts/landlock-regression.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/bin/sh
# Live regression for fail-closed confinement.
#
# The property under test: a sandbox is never handed out with weaker isolation
# than asked for, and the client can always find out what it got. Plus the
# ordinary things a sandbox needs from a narrowed /dev still work.
#
# docker run --rm -v "$PWD:/src" -w /src sh scripts/landlock-regression.sh
set -eu

BIN=${BIN:-target/x86_64-unknown-linux-musl/release/sbx-server}
PORT=${PORT:-49501}
TOKEN=t
U="http://127.0.0.1:$PORT"
failures=0

say() { printf '\n=== %s\n' "$1"; }
pass() { printf ' ok %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); }

stop() { kill "$server" 2>/dev/null || true; wait "$server" 2>/dev/null || true; }

command -v curl >/dev/null || { apt-get update -qq && apt-get install -y -qq curl >/dev/null; }
[ -f "$BIN" ] || cargo build --release --target x86_64-unknown-linux-musl

# What this kernel actually offers; the checks below adapt rather than assuming.
# On its own port, so the probe cannot collide with the servers started below.
PROBE_PORT=$((PORT + 50))
SBX_PORT=$PROBE_PORT SBX_TOKEN=$TOKEN "$BIN" >/tmp/probe.log 2>&1 &
probe=$!
sleep 1
ABI=$(curl -s "http://127.0.0.1:$PROBE_PORT/health" | sed 's/.*"abi":\([0-9]*\).*/\1/')
kill "$probe" 2>/dev/null || true
wait "$probe" 2>/dev/null || true
echo "kernel landlock ABI: $ABI"

say "/health reports the confinement the client is getting"
SBX_PORT=$PORT SBX_TOKEN=$TOKEN SBX_HOST_MODE=1 SBX_MIN_LANDLOCK_ABI=1 "$BIN" >/tmp/log 2>&1 &
server=$!
sleep 1
curl -s "$U/health" >/tmp/body
grep -q '"abi"' /tmp/body && pass "abi is reported" || fail "no abi in /health: $(cat /tmp/body)"
grep -q '"features"' /tmp/body && pass "features are reported" || fail "no features in /health"
grep -q 'abi [0-9]* \[' /tmp/log && pass "startup log states the ABI and features" || fail "startup log: $(head -c 200 /tmp/log)"

say "a created sandbox reports how it is confined"
curl -s -H "X-Sandbox-Token: $TOKEN" -X POST "$U/v1/sandboxes" -d '{"count":1}' >/tmp/created
S=$(sed 's/.*"id":"\([^"]*\)".*/\1/' /tmp/created)
grep -q '"confinement":"landlock"' /tmp/created &&
pass "create response says landlock" ||
fail "create response confinement: $(head -c 200 /tmp/created)"
curl -s -H "X-Sandbox-Token: $TOKEN" "$U/v1/sandboxes" >/tmp/body
grep -q '"confinement":"landlock"' /tmp/body && pass "list says landlock" || fail "list confinement missing"

say "the narrowed /dev still serves a normal workload"
T=$(sed 's/.*"token":"\([^"]*\)".*/\1/' /tmp/created)
# argv form, so nothing here needs shell-quoting inside JSON.
run_argv() {
curl -s -m 60 -H "X-Sandbox-Token: $T" -X POST "$U/v1/sandboxes/$S/exec" \
-d "{\"cmd\":[\"/bin/sh\",\"-c\",$1]}"
}
probe() { # $1 JSON-quoted shell command, $2 marker
out=$(run_argv "$1")
case "$out" in
*"$2"*) pass "probe: $2" ;;
*) fail "probe $2: $(printf '%s' "$out" | head -c 220)" ;;
esac
}

probe '"cat /dev/null && echo DEVOK"' DEVOK
probe '"head -c 8 /dev/urandom >/dev/null && echo RANDOK"' RANDOK
probe '"echo x >/dev/null && echo WRITEOK"' WRITEOK
probe '"echo y 2>/dev/null && echo REDIROK"' REDIROK
probe '"python3 -c 1 && echo PYOK"' PYOK
probe '"python3 -c \"import ssl,hashlib,random,os\" && echo SSLOK"' SSLOK
probe '"[ -w /dev/null ] && echo DEVWRITABLE"' DEVWRITABLE
# A --user install is the documented way to add packages in a pooled sandbox.
probe '"python3 -m pip install --user -q --disable-pip-version-check six >/dev/null 2>&1; python3 -c \"import six\" && echo PIPOK"' PIPOK

say "the documented denials actually hold"
# These are the guarantees the isolation model advertises. Asserting them here
# means a future change to the ruleset cannot quietly drop one.
denied() { # $1 JSON-quoted command, $2 label
out=$(run_argv "$1")
case "$out" in
*DENIED*) pass "denied: $2" ;;
*) fail "NOT denied: $2 -> $(printf '%s' "$out" | head -c 200)" ;;
esac
}
denied '"echo x > /tmp/escape 2>/dev/null || echo DENIED"' "write to /tmp"
denied '"echo x > /dev/shm/escape 2>/dev/null || echo DENIED"' "write to /dev/shm"
denied '"cat /etc/shadow >/dev/null 2>&1 || echo DENIED"' "read /etc/shadow"
denied '"echo x > /etc/passwd 2>/dev/null || echo DENIED"' "write to /etc"
denied '"echo x > /dev/kmsg 2>/dev/null || echo DENIED"' "write to an ungranted device node"
# A second sandbox's home, named directly (not via a symlink -- that is covered
# by the file-API regression).
curl -s -H "X-Sandbox-Token: $TOKEN" -X POST "$U/v1/sandboxes" -d '{"count":1}' >/tmp/other
OTHER=$(sed 's/.*"home":"\([^"]*\)".*/\1/' /tmp/other)
denied "\"ls $OTHER >/dev/null 2>&1 || echo DENIED\"" "read a sibling's home"
denied "\"echo x > $OTHER/planted 2>/dev/null || echo DENIED\"" "write into a sibling's home"

if [ "${ABI:-0}" -ge 4 ]; then
probe '"python3 -c \"import socket,sys; s=socket.socket()\ntry:\n s.bind((\\\"127.0.0.1\\\",18080)); print(\\\"BOUND\\\")\nexcept Exception: print(\\\"DENIED\\\")\"" ' DENIED
else
printf ' skip TCP bind denial needs landlock ABI 4 (this kernel: %s)\n' "${ABI:-?}"
fi

stop

say "host mode refuses to start below the required ABI"
code=0
timeout 5 env SBX_PORT=$PORT SBX_TOKEN=$TOKEN SBX_HOST_MODE=1 SBX_MIN_LANDLOCK_ABI=99 "$BIN" >/tmp/log 2>&1 || code=$?
case $code in
124) fail "started below the ABI floor" ;;
*) grep -q 'required for the documented isolation guarantees' /tmp/log &&
pass "refused, naming what is missing" ||
fail "exited $code without explaining: $(head -c 200 /tmp/log)" ;;
esac

say "dedicated mode is not gated on the ABI (its boundary is the VM)"
SBX_PORT=$PORT SBX_TOKEN=$TOKEN SBX_MIN_LANDLOCK_ABI=99 "$BIN" >/tmp/log 2>&1 &
server=$!
sleep 1
curl -s -o /dev/null -w '%{http_code}' "$U/health" | grep -q 200 &&
pass "dedicated mode still starts" || fail "dedicated mode was gated"
stop

say "--allow-unconfined is the only way to get uid-only isolation"
SBX_PORT=$PORT SBX_TOKEN=$TOKEN SBX_HOST_MODE=1 SBX_MIN_LANDLOCK_ABI=99 "$BIN" --allow-unconfined >/tmp/log 2>&1 &
server=$!
sleep 1
if curl -s -o /dev/null -w '%{http_code}' "$U/health" | grep -q 200; then
pass "starts with the flag"
else
fail "did not start even with --allow-unconfined: $(head -c 200 /tmp/log)"
fi
stop

say "result"
if [ "$failures" -eq 0 ]; then
echo "all checks passed"
else
echo "$failures check(s) failed"
exit 1
fi
6 changes: 6 additions & 0 deletions scripts/symlink-regression.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ BASE="http://127.0.0.1:$PORT"
AUTH="X-Sandbox-Token: $TOKEN"
failures=0

# Landlock ABI floor: these checks are about other properties, so accept whatever
# the test kernel offers rather than requiring the production floor (CI and dev
# kernels are often older). scripts/landlock-regression.sh covers the floor.
export SBX_MIN_LANDLOCK_ABI=1


say() { printf '\n=== %s\n' "$1"; }
pass() { printf ' ok %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); }
Expand Down
6 changes: 6 additions & 0 deletions scripts/token-scope-regression.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ HOST_TOKEN=host-management-token
U="http://127.0.0.1:$PORT"
failures=0

# Landlock ABI floor: these checks are about other properties, so accept whatever
# the test kernel offers rather than requiring the production floor (CI and dev
# kernels are often older). scripts/landlock-regression.sh covers the floor.
export SBX_MIN_LANDLOCK_ABI=1


say() { printf '\n=== %s\n' "$1"; }
pass() { printf ' ok %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); }
Expand Down
Loading
Loading