diff --git a/README.md b/README.md index 325ef4e..35dc8fd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/scripts/auth-routes-regression.sh b/scripts/auth-routes-regression.sh index f135d8e..18aba8b 100644 --- a/scripts/auth-routes-regression.sh +++ b/scripts/auth-routes-regression.sh @@ -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)); } diff --git a/scripts/landlock-regression.sh b/scripts/landlock-regression.sh new file mode 100644 index 0000000..db200ce --- /dev/null +++ b/scripts/landlock-regression.sh @@ -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 diff --git a/scripts/symlink-regression.sh b/scripts/symlink-regression.sh index 55b917c..e734a0f 100644 --- a/scripts/symlink-regression.sh +++ b/scripts/symlink-regression.sh @@ -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)); } diff --git a/scripts/token-scope-regression.sh b/scripts/token-scope-regression.sh index dd059df..ae8df49 100644 --- a/scripts/token-scope-regression.sh +++ b/scripts/token-scope-regression.sh @@ -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)); } diff --git a/src/landlock.rs b/src/landlock.rs index 4f60f0b..e6c16ed 100644 --- a/src/landlock.rs +++ b/src/landlock.rs @@ -98,6 +98,45 @@ pub fn available() -> bool { cached_abi() >= 1 } +/// The kernel's Landlock ABI version. +pub fn abi() -> i32 { + cached_abi() +} + +/// The lowest ABI that delivers everything the isolation model claims. +/// +/// The filesystem confinement works from ABI 1, but two of the documented +/// guarantees need more: refusing a TCP bind (so there is no inter-sandbox +/// localhost service) needs ABI 4, and scoping abstract unix sockets — which +/// uid isolation alone does *not* block — needs ABI 6. Accepting a lower ABI +/// silently dropped both. +pub const FULL_ABI: i32 = 6; + +/// Human-readable list of the guarantees this kernel's ABI can enforce, for the +/// startup log and `/health`. +pub fn features(abi: i32) -> Vec<&'static str> { + let mut features = Vec::new(); + if abi >= 1 { + features.push("fs"); + } + if abi >= 2 { + features.push("refer"); + } + if abi >= 3 { + features.push("truncate"); + } + if abi >= 4 { + features.push("no_tcp_bind"); + } + if abi >= 5 { + features.push("ioctl_dev"); + } + if abi >= 6 { + features.push("scoped_abstract_unix"); + } + features +} + /// Open `path` as an O_PATH fd (used only to identify the inode for a rule). /// Returns None if the path is absent in this image. fn open_o_path(path: &str) -> Option { @@ -108,23 +147,38 @@ fn open_o_path(path: &str) -> Option { } /// Add a PATH_BENEATH rule to `ruleset_fd` for an already-open `parent_fd`. -fn add_fd_rule(ruleset_fd: RawFd, parent_fd: RawFd, access: u64) { +/// +/// The return value used to be discarded. A rule that fails to attach yields a +/// ruleset that is not the one we described — most likely narrower, so things +/// break rather than open up, but silently either way. Report it and let the +/// caller decide. +fn add_fd_rule(ruleset_fd: RawFd, parent_fd: RawFd, access: u64) -> std::io::Result<()> { let attr = PathBeneathAttr { allowed_access: access, parent_fd }; - unsafe { + let rc = unsafe { libc::syscall( SYS_LANDLOCK_ADD_RULE, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &attr as *const _ as *const libc::c_void, 0u32, - ); + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); } + Ok(()) } -fn add_path_rule(ruleset_fd: RawFd, path: &str, access: u64) { - let Some(fd) = open_o_path(path) else { return }; - add_fd_rule(ruleset_fd, fd, access); +fn add_path_rule(ruleset_fd: RawFd, path: &str, access: u64) -> std::io::Result<()> { + let Some(fd) = open_o_path(path) else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("cannot open {path} to build a landlock rule"), + )); + }; + let result = add_fd_rule(ruleset_fd, fd, access); unsafe { libc::close(fd) }; + result } /// O_PATH fds for the static system directories, opened once and reused across @@ -140,8 +194,11 @@ pub fn system_dir_rules() -> &'static [(RawFd, u64)] { let ro = (FS_EXECUTE | FS_READ_FILE | FS_READ_DIR) & handled_fs; // Read-only data dirs (no execute): /proc and /sys are needed by many runtimes. let rd = (FS_READ_FILE | FS_READ_DIR) & handled_fs; - // /dev: read/write the standard nodes (no node creation — MAKE_* not granted). - let dev = (FS_READ_FILE | FS_WRITE_FILE | FS_READ_DIR | FS_IOCTL_DEV) & handled_fs; + // A rule on a device node is a rule on a *file*: the kernel rejects + // (EINVAL) an `allowed_access` carrying directory-only bits such as + // FS_READ_DIR, so the node and directory grants have to differ. + let dev_node = (FS_READ_FILE | FS_WRITE_FILE | FS_IOCTL_DEV) & handled_fs; + let dev_dir = (FS_READ_FILE | FS_WRITE_FILE | FS_READ_DIR | FS_IOCTL_DEV) & handled_fs; let mut rules = Vec::new(); for dir in ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/libx32", "/etc", "/opt", "/run"] { @@ -154,8 +211,21 @@ pub fn system_dir_rules() -> &'static [(RawFd, u64)] { rules.push((fd, rd)); } } - if let Some(fd) = open_o_path("/dev") { - rules.push((fd, dev)); + // Grant the individual device nodes runtimes actually need rather than + // the whole of /dev. Anything absent is skipped, and anything not listed + // (loop devices, `/dev/kmsg`, a mounted `/dev/fuse`, …) is simply not + // reachable. + for node in ["/dev/null", "/dev/zero", "/dev/full", "/dev/random", "/dev/urandom", "/dev/tty", "/dev/ptmx"] { + if let Some(fd) = open_o_path(node) { + rules.push((fd, dev_node)); + } + } + // Directories: `/dev/pts` for pseudo-terminals, `/dev/fd` (a symlink to + // /proc/self/fd) and the std* symlinks under it for shell redirection. + for dir in ["/dev/pts", "/dev/fd", "/dev/shm/../fd"] { + if let Some(fd) = open_o_path(dir) { + rules.push((fd, dev_dir)); + } } rules }) @@ -164,10 +234,13 @@ pub fn system_dir_rules() -> &'static [(RawFd, u64)] { /// Build a ruleset confining a sandbox to its `home`. Returns the ruleset fd /// (to be passed to `restrict_self` in the exec child), or None if Landlock is /// unavailable. The fd is held for the sandbox's lifetime. -pub fn build_ruleset(home: &str) -> Option { +pub fn build_ruleset(home: &str) -> std::io::Result { let abi = cached_abi(); if abi < 1 { - return None; + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "landlock is unavailable on this kernel", + )); } let handled_fs = handled_fs_for(abi); // Control TCP bind only (leave connect unrestricted → outbound internet works). @@ -179,17 +252,26 @@ pub fn build_ruleset(home: &str) -> Option { libc::syscall(SYS_LANDLOCK_CREATE_RULESET, &attr as *const _ as *const libc::c_void, std::mem::size_of::(), 0u32) } as RawFd; if ruleset_fd < 0 { - return None; + return Err(std::io::Error::last_os_error()); } // System directories are identical across sandboxes — reuse the fds opened once. + // A system dir that fails to attach only costs the sandbox access to it, so + // warn rather than refuse to create the sandbox. for &(parent_fd, access) in system_dir_rules() { - add_fd_rule(ruleset_fd, parent_fd, access); + if let Err(e) = add_fd_rule(ruleset_fd, parent_fd, access) { + eprintln!("sbx-server: landlock system-dir rule failed: {e}"); + } + } + // The sandbox's own home: full control within this subtree only. This one is + // not optional — without it the sandbox cannot use its own home, and a + // ruleset we cannot describe correctly is not one to enforce. + if let Err(e) = add_path_rule(ruleset_fd, home, handled_fs) { + unsafe { libc::close(ruleset_fd) }; + return Err(e); } - // The sandbox's own home: full control within this subtree only. - add_path_rule(ruleset_fd, home, handled_fs); - Some(ruleset_fd) + Ok(ruleset_fd) } /// Enforce the ruleset on the current thread and its future children/execve. @@ -202,3 +284,56 @@ pub fn restrict_self(ruleset_fd: RawFd) -> std::io::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The two guarantees the isolation model documents but a low ABI cannot + /// deliver. Accepting ABI 1 used to drop both silently. + #[test] + fn network_and_socket_scoping_need_a_recent_abi() { + assert!(!features(1).contains(&"no_tcp_bind")); + assert!(!features(3).contains(&"no_tcp_bind")); + assert!(features(4).contains(&"no_tcp_bind")); + + assert!(!features(5).contains(&"scoped_abstract_unix")); + assert!(features(6).contains(&"scoped_abstract_unix")); + + // FULL_ABI must be the lowest ABI offering everything we advertise. + assert!(features(FULL_ABI).contains(&"no_tcp_bind")); + assert!(features(FULL_ABI).contains(&"scoped_abstract_unix")); + assert!(!features(FULL_ABI - 1).contains(&"scoped_abstract_unix")); + } + + #[test] + fn features_grow_monotonically_with_the_abi() { + for abi in 1..=FULL_ABI { + let lower = features(abi - 1); + let higher = features(abi); + assert!( + lower.iter().all(|f| higher.contains(f)), + "abi {abi} dropped a feature its predecessor had" + ); + } + assert!(features(0).is_empty()); + } + + #[test] + fn handled_bits_only_ever_widen() { + for abi in 2..=FULL_ABI { + let lower = handled_fs_for(abi - 1); + assert_eq!(lower & handled_fs_for(abi), lower, "abi {abi} stopped handling a bit"); + } + } + + /// A ruleset we cannot describe correctly must be an error, not a + /// silently-unconfined sandbox. + #[test] + fn building_a_ruleset_for_a_missing_home_fails() { + if !available() { + return; // no Landlock on this kernel; the create path refuses anyway + } + assert!(build_ruleset("/nonexistent/sandbox/home").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index d6629c8..380df0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -181,6 +181,13 @@ fn route( "version": VERSION, "uptime_ms": now_ms() - state.started_at_ms, "sandboxes": state.sandboxes.count(), + // So a client can refuse to run untrusted work on a host whose + // confinement is weaker than it expects, instead of finding out + // by not finding out. + "landlock": { + "abi": landlock::abi(), + "features": landlock::features(landlock::abi()), + }, }), ); } @@ -335,13 +342,23 @@ fn main() { // Transitional: accept the host token on per-sandbox routes for clients that // predate per-sandbox tokens. Set to 0 to require scoped tokens. let compat_host_token = std::env::var("SBX_COMPAT_HOST_TOKEN").map(|v| v != "0").unwrap_or(true); + // Like --allow-no-auth, an argv flag rather than an env var: a Job's + // user-supplied env must not be able to turn off a sandbox's confinement. + let allow_unconfined = std::env::args().skip(1).any(|arg| arg == "--allow-unconfined"); + // The isolation model documents two guarantees that need a recent ABI (no + // TCP bind: 4; scoped abstract unix sockets: 6). Refuse to run host mode on + // a kernel that cannot deliver them, rather than silently dropping them. + let min_abi: i32 = std::env::var("SBX_MIN_LANDLOCK_ABI") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(landlock::FULL_ABI); let state = Arc::new(State { auth, started_at_ms: now_ms(), last_activity_ms: AtomicI64::new(now_ms()), procs: exec::ProcRegistry::default(), - sandboxes: sandboxes::SandboxRegistry::with_capacity(capacity), + sandboxes: sandboxes::SandboxRegistry::new(capacity, allow_unconfined), host_mode, compat_host_token, }); @@ -390,13 +407,29 @@ fn main() { std::process::exit(1); }); let landlock_ok = landlock::available(); + let abi = landlock::abi(); + // Host mode is the only mode that relies on Landlock as a boundary between + // tenants; dedicated mode's boundary is the VM. + if host_mode && abi < min_abi && !allow_unconfined { + eprintln!( + "sbx-server: landlock ABI {abi} on this kernel, but {min_abi} is required for the \ + documented isolation guarantees (have: {}). Lower SBX_MIN_LANDLOCK_ABI to accept \ + a reduced set, or pass --allow-unconfined to run without confinement.", + landlock::features(abi).join(",") + ); + std::process::exit(1); + } // Mode and auth state on the first line: a server that silently serves the // wrong surface, or no authentication at all, is the hazard worth seeing. eprintln!( "sbx-server {VERSION} listening on 0.0.0.0:{port} (mode: {}, auth: {}, landlock: {}{})", if host_mode { "host" } else { "dedicated" }, if matches!(state.auth, Auth::Required(_)) { "required" } else { "DISABLED" }, - if landlock_ok { "enabled" } else { "UNAVAILABLE — uid isolation only" }, + if landlock_ok { + format!("abi {abi} [{}]", landlock::features(abi).join(",")) + } else { + "UNAVAILABLE".to_string() + }, if host_mode && compat_host_token { ", host-token compat: on" } else { "" } ); // Host mode reuses one set of system-dir fds across every sandbox ruleset. diff --git a/src/proxy.rs b/src/proxy.rs index bf76900..efa0f3b 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -290,7 +290,7 @@ mod tests { max_procs: 16, max_mem_mb: 16, token: "sandbox-capability-token".to_string(), - landlock_fd: -1, + confinement: crate::sandboxes::Confinement::UidOnly, last_activity_ms: AtomicI64::new(0), idle_timeout_ms: 0, } diff --git a/src/sandboxes.rs b/src/sandboxes.rs index 4733df8..8e8a47b 100644 --- a/src/sandboxes.rs +++ b/src/sandboxes.rs @@ -49,14 +49,37 @@ pub struct SandboxEntry { /// sandbox — including into a browser or WebSocket client via the port /// proxy — without also conferring authority over its siblings or the host. pub token: String, - /// Landlock ruleset fd confining this sandbox (-1 if Landlock unavailable). - pub landlock_fd: i32, + /// How this sandbox is confined. + pub confinement: Confinement, /// Last time a request targeted this sandbox; drives idle eviction. pub last_activity_ms: AtomicI64, /// Evict the sandbox after this many ms with no activity (0 = never). pub idle_timeout_ms: i64, } +/// How a sandbox is confined. +/// +/// Deliberately not `landlock_fd: i32` with `-1` meaning "none". That shape let +/// a failed ruleset build become an unconfined sandbox that was still reported +/// as created, with nothing telling the client its isolation was uid-only. +pub enum Confinement { + /// Landlock ruleset fd, enforced by the exec child before it runs anything. + Landlock(i32), + /// Distinct uid and a 0700 home, and nothing else. `/tmp`, `/dev/shm`, TCP + /// bind and other homes are *not* denied. Only reachable with + /// `--allow-unconfined`. + UidOnly, +} + +impl Confinement { + pub fn label(&self) -> &'static str { + match self { + Confinement::Landlock(_) => "landlock", + Confinement::UidOnly => "uid-only", + } + } +} + /// Why a `create` was refused. pub enum CreateError { /// The host is at `capacity` — the caller should pack onto (or boot) another host. @@ -72,6 +95,9 @@ pub struct SandboxRegistry { /// Reserved slots (== live sandboxes once creation settles). Reserved up front so /// concurrent creates from different clients can't over-commit past `capacity`. reserved: AtomicUsize, + /// Whether a sandbox may be created with uid-only isolation when Landlock is + /// unavailable. Off unless the operator passed `--allow-unconfined`. + allow_unconfined: bool, } /// `n` bytes from the kernel CSPRNG, hex-encoded. @@ -87,8 +113,14 @@ fn random_hex(n: usize) -> std::io::Result { } impl SandboxRegistry { - pub fn with_capacity(capacity: usize) -> Self { - Self { map: Mutex::new(HashMap::new()), next_uid: AtomicU32::new(0), capacity, reserved: AtomicUsize::new(0) } + pub fn new(capacity: usize, allow_unconfined: bool) -> Self { + Self { + map: Mutex::new(HashMap::new()), + next_uid: AtomicU32::new(0), + capacity, + reserved: AtomicUsize::new(0), + allow_unconfined, + } } /// Create a sandbox, atomically reserving a capacity slot first. Returns @@ -149,7 +181,20 @@ impl SandboxRegistry { } // chown the .sbx/proxy chain so the sandbox uid can create sockets in it. chown_into_home(&home, Path::new(&proxy_dir), uid); - let landlock_fd = crate::landlock::build_ruleset(&home).unwrap_or(-1); + // Fail closed. A sandbox whose ruleset could not be built is not the + // thing the caller asked for, so refuse to hand one out unless the + // operator explicitly accepted uid-only isolation at startup. + let confinement = match crate::landlock::build_ruleset(&home) { + Ok(fd) => Confinement::Landlock(fd), + Err(e) if self.allow_unconfined => { + eprintln!("sbx-server: landlock unavailable ({e}); creating an UNCONFINED sandbox"); + Confinement::UidOnly + } + Err(e) => { + let _ = std::fs::remove_dir_all(&home); + return Err(std::io::Error::other(format!("cannot confine sandbox: {e}"))); + } + }; let entry = Arc::new(SandboxEntry { id: id.clone(), uid, @@ -159,7 +204,7 @@ impl SandboxRegistry { max_procs: max_procs.unwrap_or(DEFAULT_MAX_PROCS), max_mem_mb: max_mem_mb.unwrap_or(DEFAULT_MAX_MEM_MB), token, - landlock_fd, + confinement, last_activity_ms: AtomicI64::new(now_ms()), idle_timeout_ms, }); @@ -199,8 +244,8 @@ impl SandboxRegistry { let Some(entry) = self.map.lock().unwrap().remove(id) else { return false }; self.reserved.fetch_sub(1, Ordering::SeqCst); // free the capacity slot kill_uid(entry.uid); - if entry.landlock_fd >= 0 { - unsafe { libc::close(entry.landlock_fd) }; + if let Confinement::Landlock(fd) = entry.confinement { + unsafe { libc::close(fd) }; } let _ = std::fs::remove_dir_all(&entry.home); true @@ -216,6 +261,7 @@ impl SandboxRegistry { "uid": s.uid, "home": s.home, "created_at_ms": s.created_at_ms, + "confinement": s.confinement.label(), }) }) .collect(), @@ -266,7 +312,10 @@ fn pids_of_uid(uid: u32) -> Vec { pub fn pre_exec_isolation(entry: &SandboxEntry) -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static { let max_procs = entry.max_procs; let max_mem = entry.max_mem_mb * 1024 * 1024; - let landlock_fd = entry.landlock_fd; + let confinement = match entry.confinement { + Confinement::Landlock(fd) => Some(fd), + Confinement::UidOnly => None, + }; move || { unsafe { // setuid binaries (su, passwd, ...) must not elevate back to root. @@ -274,9 +323,11 @@ pub fn pre_exec_isolation(entry: &SandboxEntry) -> impl FnMut() -> std::io::Resu if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 { return Err(std::io::Error::last_os_error()); } - // Confine the filesystem/network view to this sandbox (see landlock module). - if landlock_fd >= 0 { - crate::landlock::restrict_self(landlock_fd)?; + // Confine the filesystem/network view to this sandbox (see landlock + // module). An enforcement failure aborts the child rather than + // running it unconfined. + if let Some(fd) = confinement { + crate::landlock::restrict_self(fd)?; } let nproc = libc::rlimit { rlim_cur: max_procs, rlim_max: max_procs }; let mem = libc::rlimit { rlim_cur: max_mem, rlim_max: max_mem }; @@ -366,7 +417,13 @@ pub fn handle_create( for _ in 0..count { match state.sandboxes.create(env.clone(), max_procs, max_mem_mb, idle_timeout_ms) { Ok(entry) => created.push( - serde_json::json!({"id": entry.id, "token": entry.token, "uid": entry.uid, "home": entry.home}), + serde_json::json!({ + "id": entry.id, + "token": entry.token, + "uid": entry.uid, + "home": entry.home, + "confinement": entry.confinement.label(), + }), ), // Host full: report how many we couldn't place so the client packs them // onto another host (or boots a duplicate). Not an error.