diff --git a/Cargo.lock b/Cargo.lock index 33aafd22..fff25656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,11 +1376,13 @@ version = "0.1.39" dependencies = [ "landlock", "log", + "rustix 1.1.4", "serde", "serde_json", "tempfile", "tinybox-bus", "tinybox-core", + "tinybox-linux", "windows-sys 0.61.2", ] diff --git a/crates/tinybox-core/src/capability/types.rs b/crates/tinybox-core/src/capability/types.rs index 12ef71c4..eeb88305 100644 --- a/crates/tinybox-core/src/capability/types.rs +++ b/crates/tinybox-core/src/capability/types.rs @@ -14,10 +14,16 @@ pub enum IsolationLevel { /// launching user's full privileges. None, /// The workload runs under a separate user or working directory, but shares - /// the host's process table and filesystem view. + /// the host's process table and filesystem view. A namespace backend that + /// cannot guarantee [`IsolationLevel::Kernel`] for every configuration (for + /// example, because the host network is shared) also reports this level as + /// its conservative minimum; it then states its per-configuration level + /// separately. Process, - /// Separate namespaces and cgroups. The workload cannot see host processes - /// or escape its root, but shares the host kernel. + /// Separate pid, mount, user and ipc (and, where stated, network) + /// namespaces. The workload cannot see host processes or reach paths + /// outside its root, but shares the host kernel. It does not imply a + /// seccomp filter or resource limits. Kernel, /// A separate kernel behind a hypervisor boundary. Hardware, diff --git a/crates/tinybox-jail/Cargo.toml b/crates/tinybox-jail/Cargo.toml index 5aa83263..dde05bfd 100644 --- a/crates/tinybox-jail/Cargo.toml +++ b/crates/tinybox-jail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinybox-jail" -description = "Per-process directory jail for spawned commands: Landlock, Seatbelt, and AppContainer." +description = "Per-process directory jail for spawned commands: bubblewrap, Landlock, Seatbelt, and AppContainer." documentation = "https://docs.rs/tinybox-jail" version.workspace = true edition.workspace = true @@ -30,6 +30,9 @@ serde_json.workspace = true [target.'cfg(target_os = "linux")'.dependencies] landlock = { version = "0.4", optional = true } +# `NamespaceProbe` locates `bwrap`; reused so there is one definition of how the +# Linux backends find it. +tinybox-linux.workspace = true [target.'cfg(windows)'.dependencies] # AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, and the @@ -47,5 +50,11 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] tempfile = "3" +[target.'cfg(target_os = "linux")'.dev-dependencies] +# Clears close-on-exec on a descriptor in the fd-leak regression test; the safe +# `fcntl_setfd` avoids `unsafe`, which the workspace forbids. Already in the +# lockfile through tempfile. +rustix = { version = "1", default-features = false, features = ["std", "fs"] } + [lints] workspace = true diff --git a/crates/tinybox-jail/README.md b/crates/tinybox-jail/README.md index 3cf76992..4f4042f7 100644 --- a/crates/tinybox-jail/README.md +++ b/crates/tinybox-jail/README.md @@ -20,7 +20,9 @@ spawns, never the core process itself. root the same access as the root (Landlock rule, Seatbelt `file-write*` subpath, `AppContainer` ACL), for host-owned scratch such as a per-call output-capture directory that must not land inside the root. -- Cache the default backend: Landlock on Linux kernels that support it, +- Cache the default backend: bubblewrap on Linux when `bwrap` is installed and + unprivileged user namespaces work (real process isolation, suitable for + untrusted code), else Landlock on kernels that support it, Seatbelt on macOS, otherwise the `unsupported` backend. - Spawn a `std::process::Command` inside the jail, canonicalizing `root` (and the read-only and read/write paths) first so backends never see `..` or symlink @@ -39,6 +41,7 @@ spawns, never the core process itself. | `crates/tinybox-jail/src/jail.rs` | Core types: the `Jail` description struct (builder plus `canonicalize`/`canonicalize_or_log`) and the `JailBackend` trait (`name`/`is_available`/`spawn`). | | `crates/tinybox-jail/src/detect.rs` | `pick_backend()`: first available OS backend, else an unsupported backend that fails closed. | | `crates/tinybox-jail/src/noop.rs` | `NoopBackend`: no enforcement, plain `Command::spawn`. Always available. | +| `crates/tinybox-jail/src/bwrap/` | Bubblewrap backend (Linux only): user/pid/ipc/uts (+net) namespaces, grants as bind mounts, `deny_read` masked, `--die-with-parent`. `isolation()` is the minimum over configurations (`Process` with `--disable-userns`, else `None`); `Kernel` only per jail without `allow_net` (`isolation_for` / `is_suitable_for_untrusted_jail`; hosts must use the per-jail form). Never looks `bwrap` up on `PATH`; the probe is a sanity check, not authentication, and `with_program` skips the ownership check. The caller's environment and credentials travel in a private `--args` file, not in argv or any host process environment (`launcher.rs`), and inherited descriptors above 2 are closed by that launcher. No seccomp filter and no resource limits yet. Landlock is the fallback. Landlock is not layered inside because the kernel denies `mount` to a Landlock-restricted thread. | | `crates/tinybox-jail/src/linux.rs` | Landlock backend (Linux only, `landlock` feature, on by default). Applies the ruleset to a dedicated spawn thread so no `unsafe` `pre_exec` is needed. | | `crates/tinybox-jail/src/macos.rs` | Seatbelt backend via `sandbox-exec`. Compiled on every host so the profile renderer is unit-tested everywhere; selected only on macOS. | | `crates/tinybox-jail/src/windows.rs` | AppContainer implementation; **not compiled**: it needs `unsafe` FFI the workspace forbids and cannot return a waitable `std::process::Child` yet. | diff --git a/crates/tinybox-jail/src/bwrap/args.rs b/crates/tinybox-jail/src/bwrap/args.rs new file mode 100644 index 00000000..dbb4a400 --- /dev/null +++ b/crates/tinybox-jail/src/bwrap/args.rs @@ -0,0 +1,337 @@ +//! Pure construction of the `bwrap` command line for a [`Jail`]. +//! +//! What a sandbox binds and unshares *is* the security boundary, so every +//! decision here is a value a test can assert on. The only I/O is metadata +//! reads (is a baseline path a symlink, is a denied path a directory). +//! +//! # Mount model +//! +//! Baseline system paths and the jail's grants are one list of mounts. They are +//! emitted shallowest path first so a nested read-only grant lands on top of +//! the writable grant that contains it (a later mount wins). `deny_read` is +//! applied to that whole list, baseline included: a mount at or below a denied +//! path is dropped, and a denied path inside a surviving mount is masked. + +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::jail::Jail; + +/// System directories bound read-only (or re-created as symlinks) so a shell, +/// the dynamic loader and the C library can start. Missing entries are skipped. +pub(super) const SYSTEM_DIRS: &[&str] = &[ + "/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32", +]; + +/// The few `/etc` entries ordinary programs need (identity, name resolution, +/// TLS roots, the loader cache). Deliberately not all of `/etc`: that would +/// expose host keys and credentials the user can read. +pub(super) const ETC_ENTRIES: &[&str] = &[ + "/etc/passwd", + "/etc/group", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/resolv.conf", + "/etc/localtime", + "/etc/ld.so.cache", + "/etc/ld.so.conf", + "/etc/ld.so.conf.d", + "/etc/alternatives", + "/etc/ssl", + "/etc/ca-certificates", + "/etc/pki", +]; + +/// Switches that depend on the `bwrap` build or the host, decided by the probe. +#[derive(Debug, Clone, Copy)] +pub(super) struct Options { + /// Pass `--disable-userns` (bubblewrap 0.8+): the child cannot create + /// nested user namespaces, which closes the usual escape route to a + /// capability-bearing namespace. + pub disable_userns: bool, +} + +#[derive(Debug)] +enum Kind { + /// A symlink created inside the sandbox root (merged-`/usr` layouts). + Symlink(PathBuf), + Bind { + writable: bool, + }, +} + +#[derive(Debug)] +struct Mount { + path: PathBuf, + kind: Kind, +} + +impl Mount { + fn is_bind(&self) -> bool { + matches!(self.kind, Kind::Bind { .. }) + } + + fn writable(&self) -> bool { + matches!(self.kind, Kind::Bind { writable: true }) + } + + /// Sort key: shallow first; at equal depth a read-only mount goes last so + /// it wins over a writable one on the same path. + fn order(&self) -> (usize, bool) { + (self.path.components().count(), !self.writable()) + } +} + +fn os(s: &str) -> OsString { + s.into() +} + +/// Refuses a path the private-mount guard could be talked around with: only +/// absolute paths without `..` are meaningful here (`/usr/../tmp` is `/tmp`). +fn require_clean(path: &Path) -> io::Result<()> { + use std::os::unix::ffi::OsStrExt as _; + if path.is_absolute() + && !path.as_os_str().as_bytes().contains(&0) + && !path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "jail path {} must be absolute, without `..` components or NUL bytes", + path.display() + ), + )) +} + +/// Whether a grant would replace one of the sandbox's private mounts. +fn replaces_private_mount(path: &Path) -> bool { + path == Path::new("/") + || path == Path::new("/tmp") + || path.starts_with("/proc") + || path.starts_with("/dev") +} + +/// The baseline mounts: system directories (or their symlinks) and the short +/// `/etc` allow-list. +fn baseline_mounts() -> Vec { + let mut mounts: Vec = Vec::new(); + for dir in SYSTEM_DIRS { + let path = PathBuf::from(dir); + mounts.push(match std::fs::read_link(&path) { + Ok(target) => Mount { + path, + kind: Kind::Symlink(target), + }, + Err(_) => Mount { + path, + kind: Kind::Bind { writable: false }, + }, + }); + } + for entry in ETC_ENTRIES { + mounts.push(Mount { + path: PathBuf::from(entry), + kind: Kind::Bind { writable: false }, + }); + } + + mounts +} + +/// Everything after the `bwrap` program name and before the `--` separator. +/// +/// - new user, pid, ipc, uts (and cgroup where supported) namespaces; a new +/// network namespace unless `jail.allow_net`; optionally `--disable-userns`; +/// - `--die-with-parent` and `--new-session`; +/// - a private `/proc`, `/dev` and `/tmp`; +/// - baseline system paths and the root, `read_write` and `read_only` grants, +/// shallowest first (see the module docs); +/// - `deny_read` subtracted last. +/// +/// # Errors +/// - `NotFound` when the root or a `read_write` grant does not exist; +/// - `InvalidInput` for a relative path or one containing `..`, for a symlink +/// below a writable grant that a nested grant goes through, for a grant that is `/`, `/tmp`, or at or under `/proc` or +/// `/dev` (it would replace a private mount and expose the host's), and for +/// a `deny_read` entry nested more than one level inside a writable mount +/// (the child could rename a parent directory and walk around the mask). +pub(super) fn jail_args( + jail: &Jail, + cwd: Option<&Path>, + options: Options, +) -> io::Result> { + for path in std::iter::once(&jail.root) + .chain(&jail.read_write) + .chain(&jail.read_only) + .chain(&jail.deny_read) + .map(PathBuf::as_path) + .chain(cwd) + { + require_clean(path)?; + } + // A grant inside a writable one must not reach its target through a + // symlink the jailed program could have planted. + let writable: Vec<&Path> = std::iter::once(&jail.root) + .chain(&jail.read_write) + .map(PathBuf::as_path) + .collect(); + let nested: Vec<&Path> = jail + .read_only + .iter() + .chain(&jail.read_write) + .map(PathBuf::as_path) + .collect(); + crate::jail::reject_symlinked_grants(&writable, &nested)?; + let mut mounts = baseline_mounts(); + for (path, writable) in std::iter::once((&jail.root, true)) + .chain(jail.read_write.iter().map(|p| (p, true))) + .chain(jail.read_only.iter().map(|p| (p, false))) + { + if replaces_private_mount(path) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "jail grant {} would replace a private sandbox mount (/, /tmp, /proc, /dev)", + path.display() + ), + )); + } + if writable && !path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("jail path {} does not exist", path.display()), + )); + } + mounts.push(Mount { + path: path.clone(), + kind: Kind::Bind { writable }, + }); + } + + // A denial spelled through a baseline symlink (`/bin/sh`) also denies where + // the link points (`/usr/bin/sh`): the sandbox mounts the target, not the + // link. + let denied = effective_denials(&jail.deny_read, &mounts); + // A mount at or below a denied path is never mounted. + mounts.retain(|m| !denied.iter().any(|deny| m.path.starts_with(deny))); + mounts.sort_by_key(Mount::order); + + let mut argv: Vec = ["--unshare-user", "--unshare-pid", "--unshare-ipc"] + .into_iter() + .map(os) + .collect(); + argv.extend(["--unshare-uts", "--unshare-cgroup-try"].map(os)); + if !jail.allow_net { + argv.push(os("--unshare-net")); + } + if options.disable_userns { + argv.push(os("--disable-userns")); + } + argv.extend(["--die-with-parent", "--new-session"].map(os)); + + // `/tmp` first: a grant living under it (a scratch root) is bound on top. + for (flag, path) in [("--proc", "/proc"), ("--dev", "/dev"), ("--tmpfs", "/tmp")] { + argv.extend([os(flag), os(path)]); + } + + for mount in &mounts { + match &mount.kind { + Kind::Symlink(target) => argv.extend([ + os("--symlink"), + target.clone().into_os_string(), + mount.path.clone().into_os_string(), + ]), + Kind::Bind { writable } => argv.extend([ + os(if *writable { "--bind" } else { "--ro-bind-try" }), + mount.path.clone().into_os_string(), + mount.path.clone().into_os_string(), + ]), + } + } + + argv.extend(deny_masks(&denied, &mounts)?); + + argv.extend([ + os("--chdir"), + cwd.unwrap_or(&jail.root).as_os_str().to_os_string(), + ]); + Ok(argv) +} + +/// `deny_read` plus, for each entry under a baseline symlink, the same path +/// under the link's target. +fn effective_denials(deny_read: &[PathBuf], mounts: &[Mount]) -> Vec { + let mut denied = deny_read.to_vec(); + for deny in deny_read { + for mount in mounts { + let Kind::Symlink(target) = &mount.kind else { + continue; + }; + let Ok(rest) = deny.strip_prefix(&mount.path) else { + continue; + }; + let base = if target.is_absolute() { + target.clone() + } else { + mount + .path + .parent() + .map_or_else(|| target.clone(), |parent| parent.join(target)) + }; + denied.push(base.join(rest)); + } + } + denied.sort(); + denied.dedup(); + denied +} + +/// Arguments that subtract `deny_read` from the surviving mounts. +fn deny_masks(denied: &[PathBuf], mounts: &[Mount]) -> io::Result> { + let mut argv: Vec = Vec::new(); + for deny in denied { + // The deepest surviving mount that holds the denied path decides. + let Some(holder) = mounts + .iter() + .filter(|m| m.is_bind() && deny.starts_with(&m.path)) + .max_by_key(|m| m.path.components().count()) + else { + continue; + }; + if holder.writable() && deny.parent() != Some(holder.path.as_path()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "deny_read {} is nested inside writable grant {}; the child could rename \ + a parent directory and bypass the mask (deny a direct child of the grant, \ + or make the grant read-only)", + deny.display(), + holder.path.display() + ), + )); + } + match std::fs::metadata(deny) { + Ok(meta) if meta.is_dir() => { + argv.extend([os("--tmpfs"), deny.clone().into_os_string()]); + } + Ok(_) => argv.extend([ + os("--ro-bind"), + os("/dev/null"), + deny.clone().into_os_string(), + ]), + // Nothing there to hide. A path created after spawn is NOT + // protected; deny paths must exist when the jail starts. + Err(_) => {} + } + } + Ok(argv) +} + +#[cfg(test)] +#[path = "args_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/bwrap/args_tests.rs b/crates/tinybox-jail/src/bwrap/args_tests.rs new file mode 100644 index 00000000..7b21a2c1 --- /dev/null +++ b/crates/tinybox-jail/src/bwrap/args_tests.rs @@ -0,0 +1,340 @@ +//! Tests for the `bwrap` argument builder. + +use super::*; + +const OPTS: Options = Options { + disable_userns: true, +}; + +fn build(jail: &Jail) -> io::Result> { + jail_args(jail, None, OPTS).map(|argv| { + argv.iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect() + }) +} + +fn triple(argv: &[String], kind: &str, path: &Path) -> Option { + let p = path.display().to_string(); + argv.windows(3) + .position(|w| w[0] == kind && w[1] == p && w[2] == p) +} + +fn pair(argv: &[String], flag: &str, path: &Path) -> Option { + let p = path.display().to_string(); + argv.windows(2).position(|w| w[0] == flag && w[1] == p) +} + +fn jail(root: &Path) -> Jail { + Jail::new(root, "bwrap-test") +} + +#[test] +fn namespaces_and_lifetime_flags_are_always_present() -> io::Result<()> { + let root = tempfile::tempdir()?; + let argv = build(&jail(root.path()))?; + for flag in [ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--disable-userns", + "--die-with-parent", + "--new-session", + ] { + assert!(argv.iter().any(|a| a == flag), "missing {flag}"); + } + Ok(()) +} + +#[test] +fn disable_userns_is_omitted_when_the_probe_found_no_support() -> io::Result<()> { + let root = tempfile::tempdir()?; + let argv = jail_args( + &jail(root.path()), + None, + Options { + disable_userns: false, + }, + )?; + assert!(!argv.iter().any(|a| a == "--disable-userns")); + Ok(()) +} + +#[test] +fn network_is_unshared_only_when_denied() -> io::Result<()> { + let root = tempfile::tempdir()?; + assert!( + !build(&jail(root.path()))? + .iter() + .any(|a| a == "--unshare-net") + ); + assert!( + build(&jail(root.path()).deny_net())? + .iter() + .any(|a| a == "--unshare-net") + ); + Ok(()) +} + +#[test] +fn grants_become_bind_mounts_at_their_own_paths() -> io::Result<()> { + let root = tempfile::tempdir()?; + let ro = tempfile::tempdir()?; + let rw = tempfile::tempdir()?; + let j = jail(root.path()) + .add_read_only(ro.path()) + .add_read_write(rw.path()); + let argv = build(&j)?; + assert!(triple(&argv, "--bind", root.path()).is_some()); + assert!(triple(&argv, "--bind", rw.path()).is_some()); + assert!(triple(&argv, "--ro-bind-try", ro.path()).is_some()); + assert!(triple(&argv, "--bind", ro.path()).is_none()); + Ok(()) +} + +#[test] +fn ungranted_paths_and_the_whole_of_etc_are_never_mounted() -> io::Result<()> { + let root = tempfile::tempdir()?; + let other = tempfile::tempdir()?; + let argv = build(&jail(root.path()))?; + let other = other.path().display().to_string(); + assert!(!argv.contains(&other)); + for etc in ["/etc", "/"] { + assert!(triple(&argv, "--ro-bind-try", Path::new(etc)).is_none()); + } + assert!(triple(&argv, "--ro-bind-try", Path::new("/etc/alternatives")).is_some()); + Ok(()) +} + +#[test] +fn a_read_only_grant_nested_in_the_root_is_mounted_after_it() -> io::Result<()> { + let root = tempfile::tempdir()?; + let nested = root.path().join("vendor"); + std::fs::create_dir(&nested)?; + // Declared read-only grant first: order in the jail must not matter. + let j = jail(root.path()).add_read_only(&nested); + let argv = build(&j)?; + let outer = triple(&argv, "--bind", root.path()); + let inner = triple(&argv, "--ro-bind-try", &nested); + assert!(outer.is_some() && inner.is_some()); + assert!(outer < inner, "the nested read-only mount must come last"); + Ok(()) +} + +#[test] +fn a_read_only_grant_nested_in_a_read_write_grant_is_mounted_after_it() -> io::Result<()> { + let rw = tempfile::tempdir()?; + let nested = rw.path().join("sub").join("ro"); + std::fs::create_dir_all(&nested)?; + let root = tempfile::tempdir()?; + let j = jail(root.path()) + .add_read_only(&nested) + .add_read_write(rw.path()); + let argv = build(&j)?; + let outer = triple(&argv, "--bind", rw.path()); + let inner = triple(&argv, "--ro-bind-try", &nested); + assert!(outer.is_some() && inner.is_some() && outer < inner); + Ok(()) +} + +#[test] +fn grants_that_replace_a_private_mount_are_rejected() -> io::Result<()> { + let root = tempfile::tempdir()?; + for bad in ["/", "/tmp", "/proc", "/proc/1", "/dev", "/dev/shm"] { + for j in [ + jail(root.path()).add_read_only(bad), + jail(root.path()).add_read_write(bad), + jail(Path::new(bad)), + ] { + let kind = jail_args(&j, None, OPTS).err().map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::InvalidInput), "{bad}"); + } + } + Ok(()) +} + +#[test] +fn a_grant_under_tmp_is_still_allowed() -> io::Result<()> { + let root = tempfile::tempdir()?; + assert!(build(&jail(root.path())).is_ok()); + Ok(()) +} + +#[test] +fn deny_read_directly_inside_a_grant_is_masked_after_the_binds() -> io::Result<()> { + let root = tempfile::tempdir()?; + let secret_dir = root.path().join("secrets"); + std::fs::create_dir(&secret_dir)?; + let secret_file = root.path().join("token"); + std::fs::write(&secret_file, "x")?; + let mut j = jail(root.path()); + j.deny_read = vec![secret_dir.clone(), secret_file.clone()]; + let argv = build(&j)?; + let bind = triple(&argv, "--bind", root.path()); + let dir_mask = pair(&argv, "--tmpfs", &secret_dir); + let file = secret_file.display().to_string(); + let file_mask = argv + .windows(3) + .position(|w| w[0] == "--ro-bind" && w[1] == "/dev/null" && w[2] == file); + assert!(bind.is_some() && dir_mask.is_some() && file_mask.is_some()); + assert!(bind < dir_mask && bind < file_mask); + Ok(()) +} + +#[test] +fn deny_read_over_a_grant_drops_the_grant() -> io::Result<()> { + let root = tempfile::tempdir()?; + let ro = tempfile::tempdir()?; + let mut j = jail(root.path()).add_read_only(ro.path()); + j.deny_read = vec![ro.path().to_path_buf()]; + let argv = build(&j)?; + let ro = ro.path().display().to_string(); + assert!(!argv.contains(&ro)); + Ok(()) +} + +#[test] +fn deny_read_under_a_baseline_mount_is_enforced() -> io::Result<()> { + let root = tempfile::tempdir()?; + let mut j = jail(root.path()); + // A baseline file mount is dropped; a path inside /usr is masked. + j.deny_read = vec![PathBuf::from("/etc/hosts"), PathBuf::from("/usr")]; + let argv = build(&j)?; + assert!(triple(&argv, "--ro-bind-try", Path::new("/etc/hosts")).is_none()); + assert!(triple(&argv, "--ro-bind-try", Path::new("/usr")).is_none()); + assert!(triple(&argv, "--ro-bind-try", Path::new("/etc/passwd")).is_some()); + + let mut j = jail(root.path()); + j.deny_read = vec![PathBuf::from("/usr/bin")]; + if Path::new("/usr/bin").is_dir() { + let argv = build(&j)?; + assert!(triple(&argv, "--ro-bind-try", Path::new("/usr")).is_some()); + assert!(pair(&argv, "--tmpfs", Path::new("/usr/bin")).is_some()); + } + Ok(()) +} + +#[test] +fn deny_read_deeper_than_a_child_of_a_writable_grant_is_refused() -> io::Result<()> { + let root = tempfile::tempdir()?; + let nested = root.path().join("a").join("b"); + std::fs::create_dir_all(&nested)?; + let mut j = jail(root.path()); + j.deny_read = vec![nested]; + let err = jail_args(&j, None, OPTS).err(); + assert_eq!(err.map(|e| e.kind()), Some(io::ErrorKind::InvalidInput)); + + // The same path is fine when the holding mount is read-only. + let ro = tempfile::tempdir()?; + let nested = ro.path().join("a").join("b"); + std::fs::create_dir_all(&nested)?; + let mut j = jail(root.path()).add_read_only(ro.path()); + j.deny_read = vec![nested.clone()]; + assert!(pair(&build(&j)?, "--tmpfs", &nested).is_some()); + Ok(()) +} + +#[test] +fn deny_read_of_a_missing_path_adds_nothing() -> io::Result<()> { + let root = tempfile::tempdir()?; + let mut j = jail(root.path()); + j.deny_read = vec![root.path().join("later")]; + let plain = build(&jail(root.path()))?; + assert_eq!(build(&j)?, plain); + Ok(()) +} + +#[test] +fn missing_root_is_not_found() { + let missing = jail(Path::new("/nonexistent/tinybox-jail-root")); + let err = jail_args(&missing, None, OPTS).err().map(|e| e.kind()); + assert_eq!(err, Some(io::ErrorKind::NotFound)); +} + +#[test] +fn chdir_defaults_to_root_and_honours_the_command_cwd() -> io::Result<()> { + let root = tempfile::tempdir()?; + let sub = root.path().join("sub"); + let default = build(&jail(root.path()))?; + assert!(pair(&default, "--chdir", root.path()).is_some()); + let custom: Vec = jail_args(&jail(root.path()), Some(&sub), OPTS)? + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert!(pair(&custom, "--chdir", &sub).is_some()); + Ok(()) +} + +#[test] +fn relative_and_dotdot_paths_cannot_talk_around_the_private_mount_guard() -> io::Result<()> { + let root = tempfile::tempdir()?; + for bad in [ + "/usr/../tmp", + "/tmp/..", + "/usr/../proc", + "relative/dir", + "/a/./../b", + ] { + for j in [ + jail(root.path()).add_read_only(bad), + jail(root.path()).add_read_write(bad), + jail(Path::new(bad)), + ] { + let kind = jail_args(&j, None, OPTS).err().map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::InvalidInput), "{bad}"); + } + let mut j = jail(root.path()); + j.deny_read = vec![bad.into()]; + assert!(jail_args(&j, None, OPTS).is_err(), "deny {bad}"); + } + let kind = jail_args(&jail(root.path()), Some(Path::new("../x")), OPTS) + .err() + .map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::InvalidInput)); + Ok(()) +} + +#[test] +fn a_nested_grant_through_a_planted_symlink_is_refused() -> io::Result<()> { + let root = tempfile::tempdir()?; + let secret = tempfile::tempdir()?; + std::os::unix::fs::symlink(secret.path(), root.path().join("vendor"))?; + let j = jail(root.path()).add_read_only(root.path().join("vendor")); + let kind = jail_args(&j, None, OPTS).err().map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::InvalidInput)); + Ok(()) +} + +#[test] +fn nul_bytes_in_paths_are_rejected() -> io::Result<()> { + use std::os::unix::ffi::OsStrExt as _; + let root = tempfile::tempdir()?; + let evil = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/x\0--bind")); + for j in [jail(root.path()).add_read_only(evil), jail(evil)] { + let kind = jail_args(&j, None, OPTS).err().map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::InvalidInput)); + } + Ok(()) +} + +#[test] +fn a_denial_through_a_baseline_symlink_also_covers_the_link_target() -> io::Result<()> { + // `/bin` is a symlink to `usr/bin` on merged-/usr hosts. + let Ok(target) = std::fs::read_link("/bin") else { + return Ok(()); + }; + if target != Path::new("usr/bin") || !Path::new("/usr/bin/sh").exists() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let mut j = jail(root.path()); + j.deny_read = vec![PathBuf::from("/bin/sh")]; + let argv = build(&j)?; + assert!( + argv.windows(3) + .any(|w| w[0] == "--ro-bind" && w[1] == "/dev/null" && w[2] == "/usr/bin/sh"), + "{argv:?}" + ); + Ok(()) +} diff --git a/crates/tinybox-jail/src/bwrap/launcher.rs b/crates/tinybox-jail/src/bwrap/launcher.rs new file mode 100644 index 00000000..ca5a4680 --- /dev/null +++ b/crates/tinybox-jail/src/bwrap/launcher.rs @@ -0,0 +1,180 @@ +//! The launcher between the host and `bwrap`. +//! +//! Two things have to happen on the host side of the sandbox that `std` cannot +//! do safely (the workspace forbids `unsafe`): +//! +//! 1. **Keep secrets out of `argv`.** `/proc//cmdline` is world-readable, +//! so `--setenv TOKEN value` on the `bwrap` command line would hand the +//! caller's credentials to every local user. The whole `bwrap` argument +//! list is written NUL-separated to a private file (mode 0600 in a fresh +//! 0700 directory) and handed over with `bwrap --args 9`. The launcher +//! deletes the file as soon as it has it open. +//! 2. **Close inherited descriptors.** Every descriptor above 2 that the host +//! left without `O_CLOEXEC` would otherwise reach the sandbox. The launcher +//! enumerates `/proc/self/fd` *after* the fork/exec, in a single-threaded +//! process, so a descriptor another host thread opens concurrently cannot +//! slip through a stale snapshot. +//! +//! Both run in one `sh`/`bash` with an empty environment, so nothing the caller +//! set (`LD_PRELOAD`, `BASH_ENV`, exported functions) can run on the host side. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read as _, Write as _}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The script: open and delete the args file, close every other inherited +/// descriptor above 2, then `exec bwrap --args 9 -- COMMAND...`. `$0` is +/// `bwrap`, `$1` the args file, the rest the command and its arguments (bwrap +/// reads options from the file but the command itself must be on its argv; a +/// command line is visible to every local user anyway, in the sandbox as on +/// any host). +const SCRIPT: &str = r#"f=$1 +shift +exec 9<"$f" || exit 126 +command -p rm -f -- "$f" +command -p rmdir -- "${f%/*}" 2>/dev/null +[ -d /proc/self/fd ] || exit 126 +for fd in /proc/self/fd/*; do + n=${fd##*/} + case $n in + 0|1|2|9) ;; + *) eval "exec $n>&-" || exit 126 ;; + esac +done +exec "$0" --args 9 -- "$@" +"#; + +/// 24 hex characters from the kernel's random source. +/// +/// # Errors +/// Returns the error from reading `/dev/urandom`. +pub(super) fn random_hex() -> io::Result { + let mut random = [0_u8; 12]; + std::fs::File::open("/dev/urandom")?.read_exact(&mut random)?; + let mut out = String::new(); + for byte in random { + let _ = write!(out, "{byte:02x}"); + } + Ok(out) +} + +/// The shell for the launcher: `bash` when present, because `dash` (`/bin/sh` +/// on Debian and Ubuntu) cannot close a descriptor above 9 and the script then +/// fails closed. +pub(super) fn pick_shell(exists: impl Fn(&str) -> bool) -> &'static str { + ["/bin/bash", "/usr/bin/bash"] + .into_iter() + .find(|path| exists(path)) + .unwrap_or("/bin/sh") +} + +/// Where the args file may live: `$XDG_RUNTIME_DIR` (a private per-user +/// directory) when set, else the temp directory. A candidate that lies under +/// any of the jail's mounts is refused, since the jailed program could then +/// read or replace the file. +/// +/// # Errors +/// `PermissionDenied` when no candidate is outside the jail's grants. +pub(super) fn base_dir( + grants: &[&Path], + xdg_runtime_dir: Option, + temp_dir: PathBuf, +) -> io::Result { + xdg_runtime_dir + .into_iter() + .chain(std::iter::once(temp_dir)) + .find(|dir| dir.is_dir() && !grants.iter().any(|grant| dir.starts_with(grant))) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "no private directory outside the jail's grants for the bwrap argument file", + ) + }) +} + +/// A private file holding the `bwrap` argument list. +#[derive(Debug)] +pub(super) struct ArgsFile { + dir: PathBuf, + file: PathBuf, +} + +impl ArgsFile { + /// Writes `argv` NUL-terminated into a fresh private directory under + /// `base`. + /// + /// # Errors + /// `InvalidInput` if any argument contains a NUL byte (it would end the + /// argument early and let the rest be read as further `bwrap` options); + /// otherwise the filesystem error from creating the directory or file. + pub(super) fn create(argv: &[OsString], base: &Path) -> io::Result { + if argv.iter().any(|arg| arg.as_bytes().contains(&0)) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a bwrap argument contains a NUL byte", + )); + } + let dir = base.join(format!("tinybox-bwrap-{}", random_hex()?)); + // `create`, not `create_all`: an existing path is an error, never reused. + std::fs::DirBuilder::new().mode(0o700).create(&dir)?; + let file = dir.join("args"); + let written = (|| { + let mut out = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&file)?; + for arg in argv { + out.write_all(arg.as_bytes())?; + out.write_all(&[0])?; + } + out.flush() + })(); + let this = Self { dir, file }; + if let Err(error) = written { + this.discard(); + return Err(error); + } + Ok(this) + } + + /// The file's path (an argument of the launcher, not a secret). + pub(super) fn path(&self) -> &Path { + &self.file + } + + /// Removes the file and directory; the launcher normally already has. + pub(super) fn discard(&self) { + let _ = std::fs::remove_file(&self.file); + let _ = std::fs::remove_dir(&self.dir); + } +} + +/// The launcher command: an empty environment, the script, `bwrap`, the file, +/// then the command to run inside the sandbox and its arguments. +pub(super) fn command( + shell: &str, + bwrap: &Path, + args: &ArgsFile, + program: &std::ffi::OsStr, + program_args: &[OsString], +) -> Command { + let mut launcher = Command::new(shell); + launcher + .env_clear() + .arg("-c") + .arg(SCRIPT) + .arg(bwrap) + .arg(args.path()) + .arg(program) + .args(program_args); + launcher +} + +#[cfg(test)] +#[path = "launcher_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/bwrap/launcher_tests.rs b/crates/tinybox-jail/src/bwrap/launcher_tests.rs new file mode 100644 index 00000000..06374151 --- /dev/null +++ b/crates/tinybox-jail/src/bwrap/launcher_tests.rs @@ -0,0 +1,175 @@ +//! Tests for the launcher; no `bwrap` needed, a stand-in script plays it. + +use super::*; +use std::os::fd::AsRawFd; +use std::os::unix::fs::PermissionsExt as _; + +fn stand_in(dir: &Path, body: &str) -> io::Result { + let path = dir.join("fake-bwrap"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n"))?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?; + Ok(path) +} + +/// The shell production would pick. Other tests in this process hold +/// descriptors above 9 open, which `dash` refuses to close by design. +fn shell() -> &'static str { + pick_shell(|path| Path::new(path).is_file()) +} + +fn tmp() -> PathBuf { + std::env::temp_dir() +} + +fn run(shell: &str, body: &str, items: &[&str]) -> io::Result { + let dir = tempfile::tempdir()?; + let bwrap = stand_in(dir.path(), body)?; + let argv: Vec = items.iter().map(OsString::from).collect(); + let file = ArgsFile::create(&argv, &tmp())?; + let out = command(shell, &bwrap, &file, "prog".as_ref(), &["a".into()]).output()?; + file.discard(); + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +#[test] +fn shell_prefers_bash_and_falls_back_to_sh() { + assert_eq!(pick_shell(|p| p == "/bin/bash"), "/bin/bash"); + assert_eq!(pick_shell(|p| p == "/usr/bin/bash"), "/usr/bin/bash"); + assert_eq!(pick_shell(|_| false), "/bin/sh"); +} + +#[test] +fn the_args_file_is_private_nul_separated_and_never_reused() -> io::Result<()> { + use std::os::unix::fs::MetadataExt as _; + let file = ArgsFile::create( + &["--setenv".into(), "TOKEN".into(), "s3cret".into()], + &tmp(), + )?; + assert_eq!(std::fs::read(file.path())?, b"--setenv\0TOKEN\0s3cret\0"); + assert_eq!(std::fs::metadata(file.path())?.mode() & 0o777, 0o600); + let dir = file + .path() + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + assert_eq!(std::fs::metadata(&dir)?.mode() & 0o777, 0o700); + let other = ArgsFile::create(&[], &tmp())?; + assert_ne!(other.path(), file.path()); + file.discard(); + other.discard(); + assert!(!dir.exists()); + Ok(()) +} + +#[test] +fn the_launcher_hands_bwrap_the_args_on_fd_9_and_deletes_the_file() -> io::Result<()> { + let out = run( + shell(), + r#"[ "$1" = "--args" ] && [ "$2" = "9" ] && [ "$3" = "--" ] || exit 3 +cat <&9 | tr '\0' ',' +printf '|%s|%s' "$4" "$5""#, + &["--a", "value with space", "--b"], + )?; + assert_eq!(out, "--a,value with space,--b,|prog|a"); + Ok(()) +} + +#[test] +fn the_args_file_is_gone_once_the_launcher_has_started_bwrap() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let bwrap = stand_in(dir.path(), "exit 0")?; + let file = ArgsFile::create(&["x".into()], &tmp())?; + let path = file.path().to_path_buf(); + assert!( + command(shell(), &bwrap, &file, "prog".as_ref(), &[]) + .status()? + .success() + ); + assert!(!path.exists() && !path.parent().is_some_and(Path::exists)); + Ok(()) +} + +#[test] +fn the_launcher_runs_with_an_empty_environment() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let bwrap = stand_in(dir.path(), "env")?; + let file = ArgsFile::create(&[], &tmp())?; + let out = command(shell(), &bwrap, &file, "prog".as_ref(), &[]).output()?; + file.discard(); + let text = String::from_utf8_lossy(&out.stdout); + assert!( + !text.contains("HOME=") && !text.contains("LD_PRELOAD"), + "{text}" + ); + Ok(()) +} + +#[test] +fn inheritable_descriptors_including_high_numbers_are_closed_by_bash() -> io::Result<()> { + let secret = tempfile::NamedTempFile::new()?; + // Enough open files that some descriptors are above 9. + let mut files = Vec::new(); + for _ in 0..12 { + let file = std::fs::File::open(secret.path())?; + rustix::io::fcntl_setfd(&file, rustix::io::FdFlags::empty())?; + files.push(file); + } + let fds: Vec = files.iter().map(AsRawFd::as_raw_fd).collect(); + assert!(fds.iter().any(|fd| *fd > 9), "{fds:?}"); + let name = secret.path().display().to_string(); + let shell = shell(); + if !shell.ends_with("bash") { + eprintln!("skipped: no bash"); + return Ok(()); + } + let out = run(shell, "ls -l /proc/self/fd", &[])?; + assert!(!out.contains(&name), "descriptors {fds:?} survived: {out}"); + Ok(()) +} + +#[test] +fn a_launcher_that_cannot_open_the_args_file_fails_closed_without_running_bwrap() -> io::Result<()> +{ + let dir = tempfile::tempdir()?; + let marker = dir.path().join("ran"); + let bwrap = stand_in(dir.path(), &format!("touch '{}'", marker.display()))?; + let missing = ArgsFile { + dir: dir.path().join("gone"), + file: dir.path().join("gone").join("args"), + }; + let status = command(shell(), &bwrap, &missing, "prog".as_ref(), &[]).status()?; + assert_eq!(status.code(), Some(126)); + assert!(!marker.exists(), "bwrap must not run without its arguments"); + Ok(()) +} + +#[test] +fn a_nul_byte_in_an_argument_is_refused_before_anything_is_written() { + use std::os::unix::ffi::OsStringExt as _; + let evil = OsString::from_vec(b"v\0--bind".to_vec()); + let kind = ArgsFile::create(&["--setenv".into(), "K".into(), evil], &tmp()) + .err() + .map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::InvalidInput)); +} + +#[test] +fn the_args_directory_prefers_xdg_runtime_dir_but_never_one_inside_a_grant() -> io::Result<()> { + let xdg = tempfile::tempdir()?; + let temp = tempfile::tempdir()?; + let chosen = base_dir(&[], Some(xdg.path().into()), temp.path().into())?; + assert_eq!(chosen, xdg.path()); + // XDG dir inside a grant: fall back to the temp dir. + let chosen = base_dir(&[xdg.path()], Some(xdg.path().into()), temp.path().into())?; + assert_eq!(chosen, temp.path()); + // Unset or missing XDG dir: temp dir. + let missing = xdg.path().join("missing"); + let chosen = base_dir(&[], Some(missing), temp.path().into())?; + assert_eq!(chosen, temp.path()); + // Everything inside a grant: refuse. + let kind = base_dir(&[temp.path()], None, temp.path().join("sub")) + .err() + .map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::PermissionDenied)); + Ok(()) +} diff --git a/crates/tinybox-jail/src/bwrap/mod.rs b/crates/tinybox-jail/src/bwrap/mod.rs new file mode 100644 index 00000000..dab0b501 --- /dev/null +++ b/crates/tinybox-jail/src/bwrap/mod.rs @@ -0,0 +1,411 @@ +//! Linux bubblewrap backend: namespace isolation for untrusted code. +//! +//! [`BubblewrapBackend`] runs the command under `bwrap` with fresh user, pid, +//! ipc and uts namespaces (and a net namespace unless the jail allows +//! network). The child sees a private mount tree holding only the baseline +//! system paths plus the jail's grants, so it cannot read an ungranted path, +//! cannot see host processes, cannot create nested user namespaces +//! (`--disable-userns`), and dies with its parent. +//! +//! # Choosing `bwrap` +//! +//! `bwrap` is never looked up on `PATH`. Only `/usr/bin/bwrap`, +//! `/usr/local/bin/bwrap` and `/bin/bwrap` are considered, and only when owned +//! by root and not group- or other-writable. [`BubblewrapBackend::with_program`] +//! lets a host name another binary: it **skips that ownership check**, so the +//! host is vouching for the path. Either way the binary must pass a probe, run +//! once per process: a sandbox that starts, in which a host-only file is +//! invisible and the pid namespace differs from the parent's. The probe is a +//! sanity check that the thing isolates, not an authenticity check: a +//! malicious `bwrap` that prints the expected answer passes it. +//! +//! # Environment and credentials +//! +//! Nothing the caller sets reaches a host-side process. A small launcher shell +//! runs with an empty environment, and the whole `bwrap` argument list, +//! including every `--setenv NAME value`, travels in a private file read +//! through `bwrap --args 9` (see `launcher.rs`). So a token in the command's +//! environment is in neither `/proc//cmdline` (world-readable) nor in the +//! environment of a host process, and `LD_PRELOAD` and similar loader +//! variables take effect only inside the sandbox. The file is mode 0600 in a +//! fresh 0700 directory and is deleted by the launcher as soon as it is open. +//! If the launcher cannot start at all the file is removed on the error path. +//! +//! # What is claimed +//! +//! [`JailBackend::isolation`] is the **minimum over every configuration the +//! backend accepts**. It is reported as `Process`, the closest defined level +//! below `Kernel`, but note what it means here: pid, mount, user and ipc +//! namespaces and a private filesystem view, with the host *network* shared and +//! no seccomp. That is more than the plain `Process` doc describes and less than +//! `Kernel`; do not read it as "same process table". `Process` with `--disable-userns` support (bubblewrap +//! 0.8+), `None` without it. [`JailBackend::isolation_for`] reaches +//! [`IsolationLevel::Kernel`] only for a jail that denies network (a new net +//! namespace). With `allow_net` the host network namespace is shared, so host +//! loopback services and abstract sockets are reachable. Hosts must ask +//! [`JailBackend::is_suitable_for_untrusted_jail`]; the per-backend +//! `is_suitable_for_untrusted_code` is false by construction. `Kernel` here +//! means separate pid, mount, user, ipc and uts (and net) namespaces: it does +//! not imply seccomp or resource limits. +//! +//! # Known gaps (not closed in this backend) +//! +//! - No seccomp filter: `keyctl` (the user's kernel keyrings), `ptrace`, +//! `bpf`, `io_uring` and similar syscalls remain reachable. A filter needs a +//! file descriptor handed to `bwrap --seccomp`; the launcher could carry one, +//! but a vetted BPF program is not part of this change. +//! - No CPU, memory or pid limits (`tinybox-linux` offers them through a +//! `systemd-run` scope; not wired here). +//! - Check-then-use gap on nested grants: the symlink check on a grant inside a +//! writable directory (`reject_symlinked_grants`) runs before `bwrap` binds +//! the path, so a program that is already running in another session could +//! swap a directory for a symlink in between. Closing it needs `O_PATH` +//! descriptors opened with `openat2(RESOLVE_NO_SYMLINKS)` and +//! `--bind-fd`/`--ro-bind-fd` (bubblewrap 0.10+); that is follow-up work. +//! The window is only open while another sandbox with write access to the +//! same tree is running. +//! - `deny_read` paths must exist when the jail starts: a path created later +//! under a mounted grant is **not** protected. One nested more than a level +//! inside a writable grant is refused. +//! - Landlock is not layered inside: the kernel denies `mount` and +//! `pivot_root` to a Landlock-restricted thread, which would break `bwrap`'s +//! own setup. +//! - Subprocess denial is not enforced and is refused. +//! +//! # Other behaviour +//! +//! Stdio configured on the original `Command` is not recoverable from `std` +//! (as with Seatbelt); use `spawn_captured` for pipes. Descriptors above 2 that +//! the host left inheritable are closed by the launcher after it starts, +//! including ones opened by another thread at the moment of the spawn. Under +//! `dash` (no `bash` installed) a descriptor above 9 cannot be closed and the +//! sandbox fails to start rather than leak it. + +use std::ffi::OsString; +use std::io; +use std::os::unix::fs::MetadataExt as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _}; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::OnceLock; + +use tinybox_core::{Constraint, ConstraintSupport, Enforcement, IsolationLevel}; + +use crate::jail::{Jail, JailBackend}; + +mod args; +mod launcher; + +/// Backend name reported by [`BubblewrapBackend`]. +pub const BWRAP_BACKEND_NAME: &str = "bubblewrap"; + +/// Where a trusted `bwrap` may live. +const TRUSTED_PATHS: &[&str] = &["/usr/bin/bwrap", "/usr/local/bin/bwrap", "/bin/bwrap"]; + +/// One probe per process for the default (trusted-path) `bwrap`. +static DEFAULT_PROBE: OnceLock> = OnceLock::new(); + +/// A `bwrap` shown to create a sandbox, and what it supports. +#[derive(Debug, Clone)] +struct Verified { + program: PathBuf, + disable_userns: bool, +} + +/// Bubblewrap (`bwrap`) namespace jail backend. +#[derive(Debug, Clone)] +pub struct BubblewrapBackend { + verified: Option, +} + +impl Default for BubblewrapBackend { + fn default() -> Self { + Self::new() + } +} + +impl BubblewrapBackend { + /// Finds a trusted `bwrap` and probes it once per process. + #[must_use] + pub fn new() -> Self { + let verified = DEFAULT_PROBE + .get_or_init(|| { + let found = TRUSTED_PATHS + .iter() + .map(PathBuf::from) + .find(|path| is_trusted_binary(path)); + let verified = found.and_then(|path| verify(&path)); + if verified.is_none() { + log::debug!( + "[cwd_jail:bwrap] unavailable: {}", + tinybox_linux::NamespaceProbe::detect() + .problem() + .unwrap_or_else(|| "no trusted bwrap passed the probe".to_owned()) + ); + } + verified + }) + .clone(); + Self { verified } + } + + /// Uses an explicit `bwrap` binary chosen by the host. The ownership check + /// is skipped (the host vouches for the path) but the isolation probe is + /// not. Unavailable if the probe fails. + #[must_use] + pub fn with_program(program: impl Into) -> Self { + Self { + verified: verify(&program.into()), + } + } + + /// The `bwrap` binary this backend launches, when available. + #[must_use] + pub fn program(&self) -> Option<&Path> { + self.verified.as_ref().map(|v| v.program.as_path()) + } + + /// Test constructor that skips the probe, for fake launchers. + #[cfg(test)] + fn unverified(program: impl Into, disable_userns: bool) -> Self { + Self { + verified: Some(Verified { + program: program.into(), + disable_userns, + }), + } + } +} + +/// Whether `path` is a regular executable owned by root and writable by no one +/// else. +fn is_trusted_binary(path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|meta| { + meta.is_file() && meta.uid() == 0 && meta.mode() & 0o022 == 0 && meta.mode() & 0o111 != 0 + }) +} + +/// Runs throwaway sandboxes to learn whether `program` really isolates, and +/// whether it supports `--disable-userns`. +fn verify(program: &Path) -> Option { + for disable_userns in [true, false] { + if probe(program, disable_userns) { + return Some(Verified { + program: program.to_path_buf(), + disable_userns, + }); + } + } + None +} + +fn probe(program: &Path, disable_userns: bool) -> bool { + // A fresh, private, randomly named directory: never a predictable path + // another user could pre-create or symlink. + let Ok(dir) = private_probe_dir() else { + return false; + }; + let root = dir.join("root"); + let host_only = dir.join("host-only"); + let setup = std::fs::DirBuilder::new() + .mode(0o700) + .create(&root) + .and_then(|()| { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&host_only) + .map(drop) + }); + let ok = setup.is_ok() && run_probe(program, disable_userns, &root, &host_only); + let _ = std::fs::remove_dir_all(&dir); + log::debug!( + "[cwd_jail:bwrap] probe {} disable_userns={disable_userns} -> {ok}", + program.display() + ); + ok +} + +/// `$TMPDIR/tinybox-bwrap-probe-`, created mode 0700 with `create` +/// (which fails if the name exists). +fn private_probe_dir() -> io::Result { + let dir = std::env::temp_dir().join(format!("tinybox-bwrap-probe-{}", launcher::random_hex()?)); + std::fs::DirBuilder::new().mode(0o700).create(&dir)?; + Ok(dir) +} + +fn run_probe(program: &Path, disable_userns: bool, root: &Path, host_only: &Path) -> bool { + let Ok(parent_pidns) = std::fs::read_link("/proc/self/ns/pid") else { + return false; + }; + let jail = Jail::new(root, "bwrap-probe").deny_net(); + let Ok(argv) = args::jail_args(&jail, None, args::Options { disable_userns }) else { + return false; + }; + let check: Vec = [ + "-c", + "test ! -e \"$1\" && test \"$(readlink /proc/self/ns/pid)\" != \"$2\" && echo isolated", + "probe", + ] + .map(OsString::from) + .into_iter() + .chain([host_only.into(), parent_pidns.into()]) + .collect(); + // The same launcher path as a real spawn, so it is exercised too. + let Ok(base) = launcher::base_dir(&[root], xdg_runtime_dir(), std::env::temp_dir()) else { + return false; + }; + let Ok(file) = launcher::ArgsFile::create(&argv, &base) else { + return false; + }; + let shell = launcher::pick_shell(|path| Path::new(path).is_file()); + // Positive evidence is required: a launcher that exits 0 without running + // the check (`true`) must not pass. + let out = launcher::command(shell, program, &file, "/bin/sh".as_ref(), &check) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output(); + file.discard(); + out.is_ok_and(|out| out.status.success() && out.stdout == b"isolated\n") +} + +/// `$XDG_RUNTIME_DIR` when it is an absolute path. +fn xdg_runtime_dir() -> Option { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) +} + +/// A command ready to spawn, plus the argument file it will consume. +struct Prepared { + launcher: Command, + args: launcher::ArgsFile, +} + +/// Rebuilds `cmd` as a launcher + `bwrap --args` invocation of the same +/// program. +/// +/// The launcher command carries no environment and its own argv holds only +/// paths; the caller's variables and arguments live in the private args file. +fn prepare_command(verified: &Verified, jail: &Jail, cmd: &Command) -> io::Result { + let mut argv = args::jail_args( + jail, + cmd.get_current_dir(), + args::Options { + disable_userns: verified.disable_userns, + }, + )?; + argv.push("--clearenv".into()); + for (key, value) in cmd.get_envs() { + if let Some(value) = value { + argv.extend(["--setenv".into(), key.to_os_string(), value.to_os_string()]); + } + } + + let grants: Vec<&Path> = std::iter::once(&jail.root) + .chain(&jail.read_write) + .chain(&jail.read_only) + .map(PathBuf::as_path) + .collect(); + let base = launcher::base_dir(&grants, xdg_runtime_dir(), std::env::temp_dir())?; + let file = launcher::ArgsFile::create(&argv, &base)?; + let shell = launcher::pick_shell(|path| Path::new(path).is_file()); + let program_args: Vec = cmd.get_args().map(OsString::from).collect(); + let launcher = launcher::command( + shell, + &verified.program, + &file, + cmd.get_program(), + &program_args, + ); + Ok(Prepared { + launcher, + args: file, + }) +} + +impl JailBackend for BubblewrapBackend { + fn carves_denials(&self) -> bool { + true + } + + fn name(&self) -> &'static str { + BWRAP_BACKEND_NAME + } + + fn is_available(&self) -> bool { + self.verified.is_some() + } + + fn constraint_support(&self) -> ConstraintSupport { + if self.is_available() { + ConstraintSupport::NONE + .with(Constraint::Filesystem, Enforcement::Enforced) + .with(Constraint::Network, Enforcement::Enforced) + } else { + ConstraintSupport::NONE + } + } + + fn isolation(&self) -> IsolationLevel { + // The minimum over every accepted configuration: with `allow_net` the + // host network namespace is shared. `Process` is the closest defined + // level below `Kernel`. + match &self.verified { + Some(v) if v.disable_userns => IsolationLevel::Process, + _ => IsolationLevel::None, + } + } + + fn isolation_for(&self, jail: &Jail) -> IsolationLevel { + match self.isolation() { + IsolationLevel::Process if !jail.allow_net => IsolationLevel::Kernel, + level => level, + } + } + + fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { + self.launch(jail, &cmd, false) + } + + fn spawn_captured(&self, jail: &Jail, cmd: Command) -> io::Result { + self.launch(jail, &cmd, true) + } +} + +impl BubblewrapBackend { + /// Preflight, rebuild the command as `bwrap`, and spawn it. Pipes must be + /// set on the wrapper, not the original command, which is discarded. + fn launch(&self, jail: &Jail, cmd: &Command, captured: bool) -> io::Result { + let Some(verified) = &self.verified else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "bubblewrap is not available (no trusted working bwrap or user namespaces disabled)", + )); + }; + self.constraint_support() + .require(self.name(), &jail.constraints()) + .map_err(|error| io::Error::new(io::ErrorKind::Unsupported, error))?; + log::debug!("[cwd_jail:bwrap] spawning label={}", jail.label); + let Prepared { + launcher: mut wrapper, + args, + } = prepare_command(verified, jail, cmd)?; + if captured { + wrapper + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Own process group, as the Seatbelt backend does, so the host + // can signal the whole sandbox. + .process_group(0); + } + wrapper.spawn().inspect_err(|_| args.discard()) + } +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/bwrap/mod_tests.rs b/crates/tinybox-jail/src/bwrap/mod_tests.rs new file mode 100644 index 00000000..e1b04c27 --- /dev/null +++ b/crates/tinybox-jail/src/bwrap/mod_tests.rs @@ -0,0 +1,214 @@ +//! Tests for the bubblewrap backend that need no working `bwrap`. + +use super::*; +use std::os::unix::fs::PermissionsExt as _; + +fn fake_bwrap() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fake-bwrap.sh") +} + +fn available() -> BubblewrapBackend { + BubblewrapBackend::unverified("/nonexistent/bwrap", true) +} + +fn verified(program: &str) -> Verified { + Verified { + program: program.into(), + disable_userns: true, + } +} + +fn argv_of(cmd: &Command) -> Vec { + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect() +} + +#[test] +fn unavailable_backend_claims_nothing_and_refuses_to_spawn() { + let backend = BubblewrapBackend { verified: None }; + assert_eq!(backend.name(), "bubblewrap"); + assert!(!backend.is_available()); + assert_eq!(backend.isolation(), IsolationLevel::None); + assert!(!backend.is_suitable_for_untrusted_code()); + let kind = backend + .spawn(&Jail::new(".", "t"), Command::new("true")) + .err() + .map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::Unsupported)); +} + +#[test] +fn isolation_is_the_minimum_over_configurations_and_kernel_needs_a_private_network() { + let hardened = BubblewrapBackend::unverified("/x", true); + let closed = Jail::new(".", "t").deny_net(); + let open = Jail::new(".", "t"); + assert_eq!(hardened.isolation(), IsolationLevel::Process); + assert_eq!(hardened.isolation_for(&closed), IsolationLevel::Kernel); + assert_eq!(hardened.isolation_for(&open), IsolationLevel::Process); + // Status without a jail (what a host reports) is the worst case, so the + // per-backend question answers false. + assert!(!hardened.is_suitable_for_untrusted_code()); + assert!(hardened.is_suitable_for_untrusted_jail(&closed)); + assert!(!hardened.is_suitable_for_untrusted_jail(&open)); + assert!(hardened.carves_denials()); + let old = BubblewrapBackend::unverified("/x", false); + assert_eq!(old.isolation(), IsolationLevel::None); + assert_eq!(old.isolation_for(&closed), IsolationLevel::None); + assert!(!old.is_suitable_for_untrusted_jail(&closed)); + assert!(old.is_available(), "it still confines the filesystem"); +} + +#[test] +fn subprocess_denial_is_refused_because_it_is_not_enforced() { + let backend = available(); + let jail = Jail::new(".", "t").deny_subprocess(); + assert!(backend.require(&jail).is_err()); + let kind = backend + .spawn(&jail, Command::new("true")) + .err() + .map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::Unsupported)); +} + +#[test] +fn secrets_stay_out_of_argv_and_the_launcher_environment() -> io::Result<()> { + let root = tempfile::tempdir()?; + let mut cmd = Command::new("node"); + cmd.arg("server.js") + .env("GITHUB_TOKEN", "ghp_s3cret") + .env("LD_PRELOAD", "/evil.so") + .env("A", "1") + .env_remove("B"); + let jail = Jail::new(root.path(), "t"); + let prepared = prepare_command(&verified("/usr/bin/bwrap"), &jail, &cmd)?; + // The host-side process: empty environment, argv of paths only. + assert_eq!(prepared.launcher.get_envs().count(), 0); + let argv = argv_of(&prepared.launcher); + for secret in ["ghp_s3cret", "/evil.so", "GITHUB_TOKEN", "LD_PRELOAD"] { + assert!( + !argv.iter().any(|a| a.contains(secret)), + "{secret} leaked into the launcher argv: {argv:?}" + ); + } + // The values travel in the private args file instead. + let content = std::fs::read(prepared.args.path())?; + let fields: Vec<&[u8]> = content.split(|b| *b == 0).collect(); + for (k, v) in [ + ("GITHUB_TOKEN", "ghp_s3cret"), + ("LD_PRELOAD", "/evil.so"), + ("A", "1"), + ] { + assert!( + fields + .windows(3) + .any(|w| w[0] == b"--setenv" && w[1] == k.as_bytes() && w[2] == v.as_bytes()), + "{k} missing from the args file" + ); + } + assert!(fields.iter().any(|f| *f == b"--clearenv")); + // The command itself rides on the launcher's argv, after the file path. + assert_eq!(&argv[argv.len() - 2..], ["node", "server.js"]); + prepared.args.discard(); + Ok(()) +} + +#[test] +fn trust_requires_root_ownership_and_no_group_or_other_write() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let bin = dir.path().join("bwrap"); + std::fs::write(&bin, "#!/bin/sh\n")?; + let is_root = std::fs::metadata(&bin)?.uid() == 0; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?; + assert_eq!(is_trusted_binary(&bin), is_root, "owner must be root"); + for mode in [0o775, 0o757, 0o777] { + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(mode))?; + assert!(!is_trusted_binary(&bin), "{mode:o} is writable by others"); + } + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644))?; + assert!(!is_trusted_binary(&bin), "must be executable"); + assert!(!is_trusted_binary(&dir.path().join("missing"))); + Ok(()) +} + +#[test] +fn a_passthrough_shim_does_not_pass_the_isolation_probe() { + // The fake runs its arguments unconfined: the host-only file stays visible + // and the pid namespace is the parent's, so the probe must reject it. + assert!(!BubblewrapBackend::with_program(fake_bwrap()).is_available()); + for program in ["/nonexistent/bwrap", "/bin/false", "/bin/true"] { + assert!(!BubblewrapBackend::with_program(program).is_available()); + } +} + +#[test] +fn spawn_runs_the_wrapped_command_through_the_launcher() -> io::Result<()> { + let backend = BubblewrapBackend::unverified(fake_bwrap(), true); + let root = tempfile::tempdir()?; + let jail = Jail::new(root.path(), "t"); + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg("exit 7"); + assert_eq!(backend.spawn(&jail, cmd)?.wait()?.code(), Some(7)); + Ok(()) +} + +#[test] +fn spawn_captured_pipes_the_wrapper_and_delivers_env_through_the_args_file() -> io::Result<()> { + let backend = BubblewrapBackend::unverified(fake_bwrap(), true); + let root = tempfile::tempdir()?; + let jail = Jail::new(root.path(), "t"); + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg("printf %s \"$A\"").env("A", "hi"); + let out = backend.spawn_captured(&jail, cmd)?.wait_with_output()?; + // Delivered through `--setenv` in the args file, applied by the stand-in. + assert_eq!(String::from_utf8_lossy(&out.stdout), "hi"); + Ok(()) +} + +#[test] +fn spawn_with_a_missing_root_is_not_found() { + let backend = BubblewrapBackend::unverified(fake_bwrap(), true); + let kind = backend + .spawn(&Jail::new("/nonexistent/root", "t"), Command::new("true")) + .err() + .map(|e| e.kind()); + assert_eq!(kind, Some(io::ErrorKind::NotFound)); +} + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[test] +fn a_launcher_that_passes_the_probe_is_available_with_process_isolation_minimum() { + let backend = BubblewrapBackend::with_program(fixture("fake-bwrap-probe.sh")); + assert!(backend.is_available()); + assert_eq!(backend.isolation(), IsolationLevel::Process); + assert_eq!( + backend.program(), + Some(fixture("fake-bwrap-probe.sh").as_path()) + ); +} + +#[test] +fn a_bwrap_without_disable_userns_is_available_but_claims_no_isolation() { + let backend = BubblewrapBackend::with_program(fixture("fake-bwrap-old.sh")); + assert!(backend.is_available()); + assert_eq!(backend.isolation(), IsolationLevel::None); + assert!(!backend.is_suitable_for_untrusted_code()); +} + +#[test] +fn the_default_constructor_probes_only_trusted_paths() { + let backend = BubblewrapBackend::new(); + if let Some(program) = backend.program() { + assert!(TRUSTED_PATHS.iter().any(|p| Path::new(p) == program)); + assert!(is_trusted_binary(program)); + } + assert_eq!( + BubblewrapBackend::default().is_available(), + backend.is_available() + ); +} diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index fdef5b61..c1a8d801 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -34,7 +34,10 @@ impl JailBackend for UnsupportedBackend { /// waitable `std::process::Child` yet (see `windows.rs`). #[cfg(target_os = "linux")] fn candidates() -> Vec> { - vec![Arc::new(crate::linux::LandlockBackend::new())] + vec![ + Arc::new(crate::bwrap::BubblewrapBackend::new()), + Arc::new(crate::linux::LandlockBackend::new()), + ] } /// The OS backends this build knows about, strongest first. @@ -49,8 +52,8 @@ fn candidates() -> Vec> { Vec::new() } -/// Picks the first available OS backend (Landlock on Linux, Seatbelt on -/// macOS). When none works it logs a warning and returns an unsupported +/// Picks the first available OS backend (bubblewrap then Landlock on Linux, +/// Seatbelt on macOS). When none works it logs a warning and returns an unsupported /// backend whose `is_available` is `false` and whose `spawn` fails with /// `ErrorKind::Unsupported`. It never silently returns an unconfined backend: /// a caller that wants to run unconfined must choose `NoopBackend` itself. diff --git a/crates/tinybox-jail/src/detect_tests.rs b/crates/tinybox-jail/src/detect_tests.rs index b0568dc1..b943f705 100644 --- a/crates/tinybox-jail/src/detect_tests.rs +++ b/crates/tinybox-jail/src/detect_tests.rs @@ -36,12 +36,21 @@ fn detection_prefers_the_platform_backend_when_it_works() { #[cfg(all(target_os = "linux", feature = "landlock"))] #[test] -fn linux_with_landlock_selects_landlock_on_a_supporting_kernel() { - if crate::linux::LandlockBackend::new().is_available() { +fn linux_prefers_bubblewrap_then_landlock() { + if crate::bwrap::BubblewrapBackend::new().is_available() { + assert_eq!(pick_backend().name(), "bubblewrap"); + } else if crate::linux::LandlockBackend::new().is_available() { assert_eq!(pick_backend().name(), "landlock"); } } +#[cfg(target_os = "linux")] +#[test] +fn linux_candidates_rank_bubblewrap_before_landlock() { + let names: Vec<_> = candidates().iter().map(|b| b.name()).collect(); + assert_eq!(names, ["bubblewrap", "landlock"]); +} + #[test] fn windows_appcontainer_is_never_a_candidate() { assert!( diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 29b72fbe..be77ce0a 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -134,6 +134,7 @@ impl Jail { /// /// Returns the filesystem error encountered while canonicalizing `root`. pub fn canonicalize(&mut self) -> std::io::Result<()> { + self.reject_symlinked_grants()?; self.root = self.root.canonicalize()?; for p in self .read_only @@ -155,6 +156,21 @@ impl Jail { Ok(()) } + /// Runs [`reject_symlinked_grants`] over this jail's own paths. + fn reject_symlinked_grants(&self) -> std::io::Result<()> { + let writable: Vec<&Path> = std::iter::once(&self.root) + .chain(&self.read_write) + .map(PathBuf::as_path) + .collect(); + let grants: Vec<&Path> = self + .read_only + .iter() + .chain(&self.read_write) + .map(PathBuf::as_path) + .collect(); + reject_symlinked_grants(&writable, &grants) + } + /// Refuse denials that a non-carving backend cannot preserve against its /// explicit and implicit grants after path canonicalization. pub(crate) fn validate_denials( @@ -199,6 +215,74 @@ impl Jail { } } +/// Refuses a read-only or read-write grant that sits inside a writable root or +/// grant and reaches its target through a symlink below that ancestor. +/// +/// The jailed program can plant such a link (`vendor -> /secret`) in a writable +/// directory; canonicalizing the grant would silently turn a later session's +/// grant into a mount of the link's target. **Call this on the raw, +/// un-canonicalized paths**: once a grant is canonicalized the link is gone and +/// there is nothing left to detect. The ancestor is matched however the grant +/// spells it (the path itself, its canonical form, or any alias that resolves +/// to it). +/// +/// # Errors +/// `InvalidInput` naming the symlink. +pub fn reject_symlinked_grants(writable: &[&Path], grants: &[&Path]) -> std::io::Result<()> { + for ancestor in writable { + let canonical = ancestor.canonicalize().ok(); + for grant in grants { + if grant == ancestor { + continue; + } + // The shortest prefix of the grant that names the ancestor. + let mut prefixes: Vec<&Path> = grant.ancestors().collect(); + prefixes.reverse(); + let anchor = prefixes.into_iter().find(|prefix| { + *prefix == *ancestor + || canonical + .as_ref() + .is_some_and(|c| prefix.canonicalize().is_ok_and(|p| p == *c)) + }); + if let Some(anchor) = anchor { + reject_symlink_below(anchor, grant)?; + } + } + } + Ok(()) +} + +/// Errors when `path` lies inside `ancestor` (lexically) and any component +/// below `ancestor` is a symlink. A path equal to `ancestor` passes. +/// +/// # Errors +/// `InvalidInput` naming the symlink. +pub(crate) fn reject_symlink_below(ancestor: &Path, path: &Path) -> std::io::Result<()> { + let Ok(rest) = path.strip_prefix(ancestor) else { + return Ok(()); + }; + let mut current = ancestor.to_path_buf(); + for component in rest.components() { + current.push(component); + match std::fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "grant {} reaches {} through a symlink inside writable {}", + path.display(), + current.display(), + ancestor.display() + ), + )); + } + Ok(_) => {} + Err(_) => break, + } + } + Ok(()) +} + /// OS-specific enforcement of a [`Jail`]. /// /// We model spawning rather than `Command` mutation because Windows @@ -236,12 +320,18 @@ pub trait JailBackend: Send + Sync { tinybox_core::ConstraintSupport::NONE } - /// Process isolation provided by this backend, independent of path checks. + /// Process isolation this backend guarantees for **any** jail it accepts, + /// independent of path checks. A backend whose isolation depends on the + /// configuration reports its weakest case here and the actual level from + /// [`isolation_for`](Self::isolation_for). fn isolation(&self) -> tinybox_core::IsolationLevel { tinybox_core::IsolationLevel::None } - /// Whether the backend confines processes sufficiently for untrusted code. + /// Whether the backend confines processes sufficiently for untrusted code + /// under *every* configuration it accepts. Prefer + /// [`is_suitable_for_untrusted_jail`](Self::is_suitable_for_untrusted_jail) + /// when you hold the jail: it can be true where this is false. fn is_suitable_for_untrusted_code(&self) -> bool { self.isolation() >= tinybox_core::IsolationLevel::Kernel && self @@ -250,6 +340,27 @@ pub trait JailBackend: Send + Sync { == tinybox_core::Enforcement::Enforced } + /// Process isolation this backend provides for one specific `jail`. + /// + /// Defaults to [`isolation`](Self::isolation). A backend whose isolation + /// depends on the configuration (a shared network namespace when + /// `allow_net` is set) lowers it here. Hosts deciding whether to run + /// untrusted code should use + /// [`is_suitable_for_untrusted_jail`](Self::is_suitable_for_untrusted_jail). + fn isolation_for(&self, _jail: &Jail) -> tinybox_core::IsolationLevel { + self.isolation() + } + + /// Per-configuration form of + /// [`is_suitable_for_untrusted_code`](Self::is_suitable_for_untrusted_code). + fn is_suitable_for_untrusted_jail(&self, jail: &Jail) -> bool { + self.isolation_for(jail) >= tinybox_core::IsolationLevel::Kernel + && self + .constraint_support() + .enforcement(tinybox_core::Constraint::Filesystem) + == tinybox_core::Enforcement::Enforced + } + /// Inspect all hard constraints a directory jail requests. fn plan_check(&self, jail: &Jail) -> tinybox_core::PlanCheck { self.constraint_support().plan_check(&jail.constraints()) diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index bf786dc9..8bd1798c 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -129,3 +129,61 @@ fn canonicalize_deduplicates_denials_that_resolve_through_symlink_aliases() -> s assert_eq!(jail.deny_read, vec![credential.canonicalize()?]); Ok(()) } + +#[cfg(unix)] +#[test] +fn a_nested_grant_through_a_symlink_planted_in_a_writable_root_is_refused() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let secret = tempfile::tempdir()?; + std::os::unix::fs::symlink(secret.path(), root.path().join("vendor"))?; + let mut jail = Jail::new(root.path(), "t").add_read_only(root.path().join("vendor")); + let err = jail.canonicalize().err().map(|e| e.kind()); + assert_eq!(err, Some(std::io::ErrorKind::InvalidInput)); + + // Same through a read-write grant, and through a deeper component. + let rw = tempfile::tempdir()?; + std::fs::create_dir(rw.path().join("a"))?; + std::os::unix::fs::symlink(secret.path(), rw.path().join("a").join("link"))?; + let mut jail = Jail::new(root.path(), "t") + .add_read_write(rw.path()) + .add_read_only(rw.path().join("a").join("link").join("x")); + assert!(jail.canonicalize().is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_symlinked_grant_outside_any_writable_ancestor_is_still_allowed() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let target = tempfile::tempdir()?; + let holder = tempfile::tempdir()?; + let link = holder.path().join("tools"); + std::os::unix::fs::symlink(target.path(), &link)?; + let mut jail = Jail::new(root.path(), "t").add_read_only(&link); + jail.canonicalize()?; + assert_eq!(jail.read_only, vec![target.path().canonicalize()?]); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_check_sees_a_planted_link_however_the_root_is_spelled() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let secret = tempfile::tempdir()?; + std::os::unix::fs::symlink(secret.path(), root.path().join("vendor"))?; + let holder = tempfile::tempdir()?; + let alias = holder.path().join("alias"); + std::os::unix::fs::symlink(root.path(), &alias)?; + // Root given by its real path, grant spelled through the alias (and the + // reverse): both must be refused. + for (writable, grant) in [ + (root.path().to_path_buf(), alias.join("vendor")), + (alias.clone(), root.path().join("vendor")), + ] { + let err = crate::reject_symlinked_grants(&[writable.as_path()], &[grant.as_path()]) + .err() + .map(|e| e.kind()); + assert_eq!(err, Some(std::io::ErrorKind::InvalidInput), "{grant:?}"); + } + Ok(()) +} diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 8bdf9b13..b7901f41 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -12,7 +12,8 @@ //! //! | OS | Backend | Mechanism | //! |---------|---------------|--------------------------------------------| -//! | Linux | landlock | Kernel 5.13+ LSM, applied on a spawn thread | +//! | Linux | bubblewrap | `bwrap` namespaces; process isolation | +//! | Linux | landlock | Fallback: kernel 5.13+ LSM, spawn thread | //! | macOS | seatbelt | `sandbox-exec -p '' …` | //! | Windows | (not compiled)| `AppContainer`, pending a `Child` bridge | //! | other | unsupported | Spawning is rejected | @@ -61,10 +62,14 @@ pub mod registry; // deliberately not compiled: it needs `unsafe` FFI the workspace forbids and // cannot yet return a waitable `std::process::Child`. #[cfg(target_os = "linux")] +pub mod bwrap; +#[cfg(target_os = "linux")] pub mod linux; pub mod macos; -pub use jail::{Jail, JailBackend}; +#[cfg(target_os = "linux")] +pub use bwrap::{BWRAP_BACKEND_NAME, BubblewrapBackend}; +pub use jail::{Jail, JailBackend, reject_symlinked_grants}; #[cfg(target_os = "linux")] pub use linux::LandlockBackend; pub use macos::SeatbeltBackend; @@ -153,10 +158,12 @@ fn require_resources(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result< /// Spawn only after all jail constraints pass a hard preflight. /// /// This strict entry point rejects the explicitly trusted no-op backend and -/// Seatbelt's partial filesystem policy. Landlock can satisfy filesystem-only -/// requests, but offers no process isolation and is unsuitable for arbitrary -/// untrusted code. Availability and actual policy application are also checked -/// by the backend when spawning. +/// any backend that cannot enforce a requested constraint (Seatbelt's partial +/// filesystem policy, Landlock's missing network and subprocess denial). +/// Whether a backend isolates *processes* well enough for untrusted code is a +/// separate question: ask [`JailBackend::is_suitable_for_untrusted_jail`]. +/// Availability and actual policy application are also checked by the backend +/// when spawning. /// /// # Errors /// Returns `Unsupported` before spawning for partial or unsupported constraints, diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 39028c55..1e4f66be 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -319,13 +319,15 @@ fn default_backend_is_cached() { #[test] fn spawn_uses_default_backend() -> std::io::Result<()> { - let dir = std::env::temp_dir(); + // A scratch directory, not `/tmp` itself: the bubblewrap backend refuses a + // grant that would replace its private `/tmp`. + let dir = tempfile::tempdir()?; // Landlock denies everything it is not told about; give it the system // directories so `true` can run. Other backends ignore the extra grants. let jail = ["/usr", "/bin", "/lib", "/lib64"] .into_iter() .filter(|path| std::path::Path::new(path).exists()) - .fold(Jail::new(&dir, "default-spawn"), Jail::add_read_only); + .fold(Jail::new(dir.path(), "default-spawn"), Jail::add_read_only); let cmd = if cfg!(windows) { let mut c = Command::new("cmd"); c.args(["/C", "exit"]); diff --git a/crates/tinybox-jail/tests/bwrap_fd_leak.rs b/crates/tinybox-jail/tests/bwrap_fd_leak.rs new file mode 100644 index 00000000..b7ee76a4 --- /dev/null +++ b/crates/tinybox-jail/tests/bwrap_fd_leak.rs @@ -0,0 +1,92 @@ +//! Descriptor-leak regressions live in their own process: they deliberately +//! hold inheritable descriptors open, which must not disturb the other +//! integration tests. +#![cfg(target_os = "linux")] + +#[allow(dead_code)] +mod support; + +use std::io; +use std::os::fd::AsRawFd; +use std::time::Instant; + +use support::{backend, done, jail, sh}; + +#[test] +fn inheritable_host_descriptors_do_not_reach_the_sandbox() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let secret = tempfile::NamedTempFile::new()?; + std::fs::write(secret.path(), "host-secret")?; + // Simulate libraries that opened descriptors without O_CLOEXEC. Enough of + // them that some are above 9, which a plain `sh` launcher cannot close. + let mut files = Vec::new(); + for _ in 0..12 { + let file = std::fs::File::open(secret.path())?; + rustix::io::fcntl_setfd(&file, rustix::io::FdFlags::empty())?; + files.push(file); + } + let fds: Vec = files.iter().map(AsRawFd::as_raw_fd).collect(); + assert!(fds.iter().any(|fd| *fd > 9), "{fds:?}"); + let mut script = String::new(); + for fd in &fds { + script.push_str("cat /proc/self/fd/"); + script.push_str(&fd.to_string()); + script.push_str(" 2>/dev/null; "); + } + let (_, out) = sh(&backend, &jail(root.path()).deny_net(), &script)?; + assert!( + !out.contains("host-secret"), + "descriptors {fds:?} leaked: {out}" + ); + // The host's own descriptor is untouched. + assert_eq!(files.len(), 12); + done("fd-leak", start); + Ok(()) +} + +#[test] +fn descriptors_opened_by_another_thread_during_the_spawn_do_not_leak() -> io::Result<()> { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let marker = root.path().join("leak-canary-file"); + std::fs::write(&marker, "x")?; + let stop = Arc::new(AtomicBool::new(false)); + // A thread that keeps opening inheritable descriptors, the way a library + // thread without O_CLOEXEC would, while sandboxes are being started. + let opener = { + let (stop, marker) = (Arc::clone(&stop), marker.clone()); + std::thread::spawn(move || -> io::Result<()> { + while !stop.load(Ordering::Relaxed) { + let file = std::fs::File::open(&marker)?; + rustix::io::fcntl_setfd(&file, rustix::io::FdFlags::empty())?; + std::thread::yield_now(); + } + Ok(()) + }) + }; + let mut leaks = 0; + for _ in 0..150 { + let (_, out) = sh( + &backend, + &jail(root.path()).deny_net(), + "ls -l /proc/self/fd | grep -c leak-canary-file", + )?; + leaks += out.trim().parse::().map_err(io::Error::other)?; + } + stop.store(true, Ordering::Relaxed); + opener + .join() + .map_err(|_| io::Error::other("opener thread panicked"))??; + assert_eq!(leaks, 0, "descriptors opened mid-spawn reached the sandbox"); + done("fd-race", start); + Ok(()) +} diff --git a/crates/tinybox-jail/tests/bwrap_isolation.rs b/crates/tinybox-jail/tests/bwrap_isolation.rs new file mode 100644 index 00000000..3c5e1c79 --- /dev/null +++ b/crates/tinybox-jail/tests/bwrap_isolation.rs @@ -0,0 +1,477 @@ +//! Linux-only: a bubblewrap-jailed process is really isolated. +//! +//! Each test skips (with a note) when `bwrap` or unprivileged user namespaces +//! are missing, unless `TINYBOX_REQUIRE_BWRAP=1`, in which case a missing +//! sandbox is a failure. CI sets that variable so a skip can never pass for a +//! run. Every test that executes prints a `[bwrap-real]` line with its +//! duration for the CI log. +#![cfg(target_os = "linux")] + +use std::io; +use std::net::TcpListener; +use std::path::Path; +use std::process::Command; +use std::time::Instant; + +use tinybox_jail::{JailBackend, spawn_captured_with}; + +#[allow(dead_code)] +mod support; + +use support::*; + +#[test] +fn only_a_jail_with_a_private_network_is_suitable_for_untrusted_code() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + // tinymcp's default configuration shares the host network: not suitable. + assert!(!backend.is_suitable_for_untrusted_jail(&jail(root.path()))); + // The per-backend answer is the worst case, so it is never true. + assert!(!backend.is_suitable_for_untrusted_code()); + // With a private network it is suitable, given `--disable-userns` support. + let closed = jail(root.path()).deny_net(); + assert_eq!( + backend.is_suitable_for_untrusted_jail(&closed), + backend.isolation() >= tinybox_core::IsolationLevel::Process + ); + done("suitability", start); + Ok(()) +} + +#[test] +fn ungranted_paths_cannot_be_read() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let secret = tempfile::tempdir()?; + let file = secret.path().join("secret"); + std::fs::write(&file, "token")?; + let (ok, out) = sh( + &backend, + &jail(root.path()), + &format!("cat '{}'", file.display()), + )?; + assert!(!ok && !out.contains("token"), "an ungranted file leaked"); + done("ungranted", start); + Ok(()) +} + +#[test] +fn granted_paths_are_readable_and_root_is_writable() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let shared = tempfile::tempdir()?; + std::fs::write(shared.path().join("f"), "hello")?; + let j = jail(root.path()).add_read_only(shared.path()); + let script = format!( + "cat '{}' && echo w > '{}' && ! echo w > '{}'", + shared.path().join("f").display(), + root.path().join("out").display(), + shared.path().join("nope").display(), + ); + let (ok, out) = sh(&backend, &j, &script)?; + assert!(ok, "stdout: {out}"); + assert_eq!(out, "hello"); + assert_eq!(std::fs::read_to_string(root.path().join("out"))?, "w\n"); + done("granted", start); + Ok(()) +} + +#[test] +fn a_read_only_grant_nested_in_the_root_stays_read_only() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let nested = root.path().join("vendor"); + std::fs::create_dir(&nested)?; + std::fs::write(nested.join("lib"), "orig")?; + // The read-only grant is declared; the jail's own root is writable. + let j = jail(root.path()).add_read_only(&nested); + let script = format!( + "! echo evil > '{0}/lib' && ! touch '{0}/new' && echo ok > '{1}/free'", + nested.display(), + root.path().display() + ); + let (ok, out) = sh(&backend, &j, &script)?; + assert!(ok, "nested read-only grant was writable: {out}"); + assert_eq!(std::fs::read_to_string(nested.join("lib"))?, "orig"); + done("nested-ro", start); + Ok(()) +} + +#[test] +fn a_read_only_grant_nested_in_a_read_write_grant_stays_read_only() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let rw = tempfile::tempdir()?; + let nested = rw.path().join("sub").join("ro"); + std::fs::create_dir_all(&nested)?; + let j = jail(root.path()) + .add_read_only(&nested) + .add_read_write(rw.path()); + let script = format!( + "! touch '{}/x' && touch '{}/y'", + nested.display(), + rw.path().display() + ); + let (ok, _) = sh(&backend, &j, &script)?; + assert!(ok); + assert!(!nested.join("x").exists()); + done("nested-ro-in-rw", start); + Ok(()) +} + +#[test] +fn grants_replacing_private_mounts_are_refused() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + for bad in ["/", "/tmp", "/proc", "/dev"] { + let j = jail(root.path()).add_read_only(bad); + assert_eq!( + refused(&backend, &j), + Some(io::ErrorKind::InvalidInput), + "{bad}" + ); + } + done("private-mount-grants", start); + Ok(()) +} + +#[test] +fn dotdot_paths_cannot_slip_past_the_private_mount_guard() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + for bad in ["/usr/../tmp", "/tmp/..", "/usr/../proc"] { + // The trait method does not canonicalize first, so the backend itself + // must refuse what is lexically `..`. + let mut cmd = Command::new("/bin/true"); + cmd.arg("x"); + let err = backend + .spawn_captured(&jail(root.path()).add_read_only(bad), cmd) + .err() + .map(|e| e.kind()); + assert_eq!(err, Some(io::ErrorKind::InvalidInput), "{bad}"); + } + done("dotdot-grants", start); + Ok(()) +} + +#[test] +fn a_symlink_planted_in_a_writable_root_cannot_redirect_a_nested_grant() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let secret = tempfile::tempdir()?; + std::fs::write(secret.path().join("token"), "host-secret")?; + // What a previous session's child could have left behind. + std::os::unix::fs::symlink(secret.path(), root.path().join("vendor"))?; + let j = jail(root.path()).add_read_only(root.path().join("vendor")); + // Through the public API (canonicalizing) and through the trait directly. + assert_eq!(refused(&backend, &j), Some(io::ErrorKind::InvalidInput)); + let mut cmd = Command::new("/bin/true"); + cmd.arg("x"); + let direct = backend.spawn_captured(&j, cmd).err().map(|e| e.kind()); + assert_eq!(direct, Some(io::ErrorKind::InvalidInput)); + done("planted-symlink", start); + Ok(()) +} + +#[test] +fn deny_read_masks_a_file_inside_the_root() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let secret = root.path().join("secret"); + std::fs::write(&secret, "token")?; + let mut j = jail(root.path()); + j.deny_read = vec![secret.clone()]; + let (_, out) = sh(&backend, &j, &format!("cat '{}'", secret.display()))?; + assert!(!out.contains("token"), "denied file leaked: {out}"); + done("deny-file", start); + Ok(()) +} + +#[test] +fn deny_read_masks_a_runtime_directory() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let dir = root.path().join("run"); + std::fs::create_dir(&dir)?; + std::fs::write(dir.join("socket-token"), "token")?; + let mut j = jail(root.path()); + j.deny_read = vec![dir.clone()]; + let script = format!("ls '{0}'; cat '{0}/socket-token'", dir.display()); + let (_, out) = sh(&backend, &j, &script)?; + assert!( + !out.contains("socket-token") && !out.contains("token"), + "{out}" + ); + done("deny-dir", start); + Ok(()) +} + +#[test] +fn deny_read_nested_in_a_writable_grant_is_refused() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let nested = root.path().join("a").join("secret"); + std::fs::create_dir_all(&nested)?; + let mut j = jail(root.path()); + j.deny_read = vec![nested]; + assert_eq!(refused(&backend, &j), Some(io::ErrorKind::InvalidInput)); + done("deny-nested-refused", start); + Ok(()) +} + +#[test] +fn deny_read_under_baseline_mounts_is_enforced() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let mut j = jail(root.path()); + let mut denied = vec![]; + for candidate in ["/etc/hosts", "/usr/lib/os-release"] { + if Path::new(candidate).exists() { + denied.push(candidate); + } + } + j.deny_read = denied.iter().map(Into::into).collect(); + for path in &denied { + let (_, out) = sh(&backend, &j, &format!("cat '{path}'"))?; + assert!(out.is_empty(), "{path} leaked: {out}"); + } + // The rest of the baseline still works. + let (ok, _) = sh(&backend, &j, "ls /usr/bin >/dev/null")?; + assert!(ok); + done("deny-baseline", start); + Ok(()) +} + +#[test] +fn host_processes_are_invisible() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let (ok, out) = sh(&backend, &jail(root.path()), "ls /proc | grep -c '^[0-9]'")?; + assert!(ok); + let visible: u32 = out.trim().parse().map_err(io::Error::other)?; + assert!(visible <= 8, "only the jail's own processes: {visible}"); + done("pids", start); + Ok(()) +} + +#[test] +fn network_is_unreachable_when_denied_and_reachable_when_shared() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + if Command::new("bash").arg("-c").arg("true").status().is_err() { + eprintln!("skipped: bash is needed for /dev/tcp"); + return Ok(()); + } + let start = Instant::now(); + let root = tempfile::tempdir()?; + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let connect = format!("echo started; exec 3<>/dev/tcp/127.0.0.1/{port}"); + let mut cmd = Command::new("/usr/bin/env"); + cmd.args(["bash", "-c", &connect]); + let (connected, _) = started(&run(&backend, &jail(root.path()).deny_net(), cmd)?)?; + assert!(!connected, "connect must fail under deny_net"); + + // Control: with the host network shared the same connect succeeds, which + // is exactly the exposure `allow_net` carries. + let mut cmd = Command::new("/usr/bin/env"); + cmd.args(["bash", "-c", &connect]); + let (connected, _) = started(&run(&backend, &jail(root.path()), cmd)?)?; + assert!(connected, "control connect should reach the host listener"); + done("network", start); + Ok(()) +} + +#[test] +fn parent_environment_is_not_inherited_and_explicit_env_is_passed() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("printf %s \"$A-${HOME:-none}\"") + .env("A", "1"); + let out = run(&backend, &jail(root.path()), cmd)?; + assert_eq!(String::from_utf8_lossy(&out.stdout), "1-none"); + done("env", start); + Ok(()) +} + +/// Waits until `pid` has exec'd `bwrap` (the launcher shell execs it), then +/// returns its raw `cmdline` and `environ`. +fn host_process_view(pid: u32) -> io::Result<(Vec, Vec)> { + let mut reached = false; + for _ in 0..300 { + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); + if comm.trim() == "bwrap" { + reached = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if !reached { + return Err(io::Error::other("the launcher never exec'd bwrap")); + } + Ok(( + std::fs::read(format!("/proc/{pid}/cmdline"))?, + std::fs::read(format!("/proc/{pid}/environ"))?, + )) +} + +#[test] +fn credentials_never_appear_in_the_host_bwrap_cmdline_or_environment() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let mut cmd = Command::new("/bin/sleep"); + cmd.arg("5") + .env("GITHUB_TOKEN", "ghp_integration_s3cret") + .env("LD_PRELOAD", "/nonexistent/evil.so"); + let mut child = spawn_captured_with(&backend, &jail(root.path()).deny_net(), cmd)?; + let (cmdline, environ) = host_process_view(child.id())?; + let _ = child.kill(); + let _ = child.wait(); + for (name, bytes) in [("cmdline", cmdline), ("environ", environ)] { + let text = String::from_utf8_lossy(&bytes); + assert!( + !text.contains("ghp_integration_s3cret") && !text.contains("evil.so"), + "{name} of the host bwrap exposes the caller's env: {text:?}" + ); + } + done("host-cmdline-environ", start); + Ok(()) +} + +#[test] +fn a_nul_byte_in_the_environment_cannot_inject_bwrap_options() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let host_file = tempfile::tempdir()?; + let mut cmd = Command::new("/bin/true"); + // Without the check this would end the value early and make the rest a + // further bwrap option: here, a bind of the host directory into the jail. + let dir = host_file.path().display(); + cmd.env("K", format!("v\0--bind\0{dir}\0{dir}")); + let err = spawn_captured_with(&backend, &jail(root.path()).deny_net(), cmd) + .err() + .map(|e| e.kind()); + assert_eq!(err, Some(io::ErrorKind::InvalidInput)); + done("nul-injection", start); + Ok(()) +} + +#[test] +fn the_private_args_file_is_not_reachable_from_inside_the_sandbox() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("echo started; ls -l /proc/self/fd; ls /tmp") + .env("GITHUB_TOKEN", "ghp_inside_s3cret"); + let (_, text) = started(&run(&backend, &jail(root.path()).deny_net(), cmd)?)?; + assert!( + !text.contains("tinybox-bwrap"), + "args file reachable: {text}" + ); + // The host's temp directory (where the file lived) is not the sandbox's. + assert!(!text.contains("args"), "{text}"); + done("args-fd-closed", start); + Ok(()) +} + +#[test] +fn ld_preload_in_the_caller_env_only_takes_effect_inside_the_sandbox() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + let start = Instant::now(); + let root = tempfile::tempdir()?; + let mut cmd = Command::new("/bin/true"); + cmd.env("LD_PRELOAD", "/nonexistent/tinybox-evil.so"); + let out = run(&backend, &jail(root.path()).deny_net(), cmd)?; + // ld.so complains once per dynamic process that tries to preload. Exactly + // one is the sandboxed `true`; a host-side bwrap or launcher that honoured + // the variable would add more. + let complaints = String::from_utf8_lossy(&out.stderr) + .matches("cannot be preloaded") + .count(); + assert_eq!( + complaints, + 1, + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + done("ld-preload", start); + Ok(()) +} + +#[test] +fn nested_user_namespaces_are_blocked_on_a_hardened_backend() -> io::Result<()> { + let Some(backend) = backend()? else { + return Ok(()); + }; + if backend.isolation() < tinybox_core::IsolationLevel::Process { + // Only reachable on bubblewrap < 0.8; CI's bubblewrap has the flag. + if std::env::var_os("TINYBOX_REQUIRE_BWRAP").is_some_and(|v| v == "1") { + return Err(io::Error::other("bwrap lacks --disable-userns")); + } + eprintln!("skipped: bwrap lacks --disable-userns"); + return Ok(()); + } + let start = Instant::now(); + let root = tempfile::tempdir()?; + let (ok, _) = sh(&backend, &jail(root.path()).deny_net(), "unshare -U true")?; + assert!(!ok, "a nested user namespace could be created"); + done("disable-userns", start); + Ok(()) +} diff --git a/crates/tinybox-jail/tests/fixtures/fake-bwrap-old.sh b/crates/tinybox-jail/tests/fixtures/fake-bwrap-old.sh new file mode 100755 index 00000000..70cbd814 --- /dev/null +++ b/crates/tinybox-jail/tests/fixtures/fake-bwrap-old.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Stand-in for bwrap in unit tests: reads the NUL-separated argument list from +# `--args 9` like the real thing, applies --setenv, and runs the command +# unconfined. Behaviour depends on the file name: +# fake-bwrap.sh passes nothing (the isolation probe must reject it) +# fake-bwrap-probe.sh also answers the isolation probe +# fake-bwrap-old.sh like -probe, but rejects --disable-userns +name=$(basename "$0") +[ "$1" = "--args" ] && [ "$3" = "--" ] || { echo "fake bwrap: expected --args N --" >&2; exit 2; } +shift 3 +tmp=$(mktemp) || exit 2 +tr '\0' '\n' <&9 >"$tmp" +mode=opts +key= +while IFS= read -r line; do + case $mode in + opts) + case $line in + --setenv) mode=setenv_k ;; + --disable-userns) + if [ "$name" = "fake-bwrap-old.sh" ]; then + rm -f "$tmp" + echo "bwrap: Unknown option --disable-userns" >&2 + exit 1 + fi ;; + esac ;; + setenv_k) key=$line; mode=setenv_v ;; + setenv_v) export "$key=$line"; mode=opts ;; + esac +done <"$tmp" +rm -f "$tmp" +if [ "$name" != "fake-bwrap.sh" ] && [ "$1" = "/bin/sh" ]; then + case $3 in *"echo isolated"*) echo isolated; exit 0 ;; esac +fi +exec "$@" diff --git a/crates/tinybox-jail/tests/fixtures/fake-bwrap-probe.sh b/crates/tinybox-jail/tests/fixtures/fake-bwrap-probe.sh new file mode 100755 index 00000000..70cbd814 --- /dev/null +++ b/crates/tinybox-jail/tests/fixtures/fake-bwrap-probe.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Stand-in for bwrap in unit tests: reads the NUL-separated argument list from +# `--args 9` like the real thing, applies --setenv, and runs the command +# unconfined. Behaviour depends on the file name: +# fake-bwrap.sh passes nothing (the isolation probe must reject it) +# fake-bwrap-probe.sh also answers the isolation probe +# fake-bwrap-old.sh like -probe, but rejects --disable-userns +name=$(basename "$0") +[ "$1" = "--args" ] && [ "$3" = "--" ] || { echo "fake bwrap: expected --args N --" >&2; exit 2; } +shift 3 +tmp=$(mktemp) || exit 2 +tr '\0' '\n' <&9 >"$tmp" +mode=opts +key= +while IFS= read -r line; do + case $mode in + opts) + case $line in + --setenv) mode=setenv_k ;; + --disable-userns) + if [ "$name" = "fake-bwrap-old.sh" ]; then + rm -f "$tmp" + echo "bwrap: Unknown option --disable-userns" >&2 + exit 1 + fi ;; + esac ;; + setenv_k) key=$line; mode=setenv_v ;; + setenv_v) export "$key=$line"; mode=opts ;; + esac +done <"$tmp" +rm -f "$tmp" +if [ "$name" != "fake-bwrap.sh" ] && [ "$1" = "/bin/sh" ]; then + case $3 in *"echo isolated"*) echo isolated; exit 0 ;; esac +fi +exec "$@" diff --git a/crates/tinybox-jail/tests/fixtures/fake-bwrap.sh b/crates/tinybox-jail/tests/fixtures/fake-bwrap.sh new file mode 100755 index 00000000..70cbd814 --- /dev/null +++ b/crates/tinybox-jail/tests/fixtures/fake-bwrap.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Stand-in for bwrap in unit tests: reads the NUL-separated argument list from +# `--args 9` like the real thing, applies --setenv, and runs the command +# unconfined. Behaviour depends on the file name: +# fake-bwrap.sh passes nothing (the isolation probe must reject it) +# fake-bwrap-probe.sh also answers the isolation probe +# fake-bwrap-old.sh like -probe, but rejects --disable-userns +name=$(basename "$0") +[ "$1" = "--args" ] && [ "$3" = "--" ] || { echo "fake bwrap: expected --args N --" >&2; exit 2; } +shift 3 +tmp=$(mktemp) || exit 2 +tr '\0' '\n' <&9 >"$tmp" +mode=opts +key= +while IFS= read -r line; do + case $mode in + opts) + case $line in + --setenv) mode=setenv_k ;; + --disable-userns) + if [ "$name" = "fake-bwrap-old.sh" ]; then + rm -f "$tmp" + echo "bwrap: Unknown option --disable-userns" >&2 + exit 1 + fi ;; + esac ;; + setenv_k) key=$line; mode=setenv_v ;; + setenv_v) export "$key=$line"; mode=opts ;; + esac +done <"$tmp" +rm -f "$tmp" +if [ "$name" != "fake-bwrap.sh" ] && [ "$1" = "/bin/sh" ]; then + case $3 in *"echo isolated"*) echo isolated; exit 0 ;; esac +fi +exec "$@" diff --git a/crates/tinybox-jail/tests/support/mod.rs b/crates/tinybox-jail/tests/support/mod.rs new file mode 100644 index 00000000..b0758d02 --- /dev/null +++ b/crates/tinybox-jail/tests/support/mod.rs @@ -0,0 +1,82 @@ +//! Helpers shared by the bubblewrap integration tests. + +use std::io; +use std::path::Path; +use std::process::Command; +use std::time::Instant; + +use tinybox_jail::{BubblewrapBackend, Jail, JailBackend, spawn_captured_with}; + +/// The backend, `None` to skip, or an error when it is required but absent. +pub(crate) fn backend() -> io::Result> { + let backend = BubblewrapBackend::new(); + if backend.is_available() { + return Ok(Some(backend)); + } + if std::env::var_os("TINYBOX_REQUIRE_BWRAP").is_some_and(|v| v == "1") { + return Err(io::Error::other( + "TINYBOX_REQUIRE_BWRAP=1 but no working bwrap (install bubblewrap and allow \ + unprivileged user namespaces)", + )); + } + eprintln!("skipped: bwrap or unprivileged user namespaces unavailable"); + Ok(None) +} + +pub(crate) fn done(name: &str, start: Instant) { + eprintln!("[bwrap-real] {name} ok in {:?}", start.elapsed()); +} + +pub(crate) fn run( + backend: &BubblewrapBackend, + jail: &Jail, + cmd: Command, +) -> io::Result { + backend + .require(jail) + .map_err(|error| io::Error::new(io::ErrorKind::Unsupported, error))?; + spawn_captured_with(backend, jail, cmd)?.wait_with_output() +} + +/// Output of a script that first printed `started`: `(exit success, stdout +/// after the marker)`. +/// +/// # Errors +/// An error when the marker is missing, so a sandbox that failed to start can +/// never satisfy a negative assertion ("the secret is not readable") by +/// producing no output at all. +pub(crate) fn started(out: &std::process::Output) -> io::Result<(bool, String)> { + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + let Some(rest) = text.strip_prefix("started\n") else { + return Err(io::Error::other(format!( + "the sandboxed script never started (status {:?}): stdout {text:?}, stderr {:?}", + out.status, + String::from_utf8_lossy(&out.stderr) + ))); + }; + Ok((out.status.success(), rest.to_owned())) +} + +/// Runs `script` under `sh -c` inside `jail` after a `started` marker. +pub(crate) fn sh( + backend: &BubblewrapBackend, + jail: &Jail, + script: &str, +) -> io::Result<(bool, String)> { + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c").arg(format!("echo started; {script}")); + started(&run(backend, jail, cmd)?) +} + +pub(crate) fn jail(root: &Path) -> Jail { + Jail::new(root, "bwrap-it") +} + +/// Like `sh` but returns the spawn error kind when the jail is refused. +pub(crate) fn refused(backend: &BubblewrapBackend, jail: &Jail) -> Option { + let mut cmd = Command::new("/bin/true"); + cmd.arg("x"); + spawn_captured_with(backend, jail, cmd) + .err() + .map(|e| e.kind()) +} diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index e8d9070f..d686f1aa 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -132,6 +132,11 @@ impl BoxService { }) } /// Report native jail enforcement facts without starting a workload. + /// + /// The answer is the backend's guaranteed minimum for *any* configuration: + /// a backend whose isolation depends on the jail (bubblewrap with a shared + /// network namespace) reports its weaker level here. Admission decisions + /// use the per-jail form in `native_jail::prepare`. async fn jail_status(&self) -> TinyBusResult { let backend = tinybox_jail::default_backend(); let support = backend.constraint_support(); diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 89e4d4ef..dea4357a 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -17,14 +17,25 @@ pub(super) fn prepare( if !cfg!(unix) || !backend.is_available() || backend.name() == tinybox_jail::NOOP_BACKEND_NAME { return Err(unavailable()); } - if config.require_process_isolation && !backend.is_suitable_for_untrusted_code() { - return Err(unavailable()); - } if config.read_only.len() + config.read_write.len() + config.deny_read.len() > tinybox_bus::MAX_NATIVE_JAIL_GRANTS { return Err(invalid()); } + // Before anything is canonicalized: a link the jailed program planted + // (`/vendor -> `) is only visible in the raw spellings. + { + let writable: Vec<&Path> = std::iter::once(root) + .chain(config.read_write.iter().map(Path::new)) + .collect(); + let nested: Vec<&Path> = config + .read_only + .iter() + .chain(&config.read_write) + .map(Path::new) + .collect(); + tinybox_jail::reject_symlinked_grants(&writable, &nested).map_err(|_| invalid())?; + } let root = root.canonicalize().map_err(|_| invalid())?; if !root.is_dir() { return Err(invalid()); @@ -84,6 +95,11 @@ pub(super) fn prepare( return Err(unavailable()); } } + // Per-jail, never per-backend: a backend's isolation can depend on the + // configuration (a shared network namespace under `allow_net`). + if config.require_process_isolation && !backend.is_suitable_for_untrusted_jail(&jail) { + return Err(unavailable()); + } backend .require_resources(&jail) .map_err(|_| unavailable())?; diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index d7b44abd..f1f50f04 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -20,6 +20,9 @@ impl JailBackend for Backend { fn is_suitable_for_untrusted_code(&self) -> bool { self.suitable } + fn is_suitable_for_untrusted_jail(&self, _: &Jail) -> bool { + self.suitable + } fn carves_denials(&self) -> bool { self.name == "seatbelt" } @@ -548,3 +551,106 @@ fn landlock_keeps_a_denied_path_unreadable_when_created_after_preflight() assert!(!output.status.success()); Ok(()) } + +/// Isolation that depends on the configuration, as the bubblewrap backend's does. +struct NetworkSensitiveBackend; +impl JailBackend for NetworkSensitiveBackend { + fn name(&self) -> &'static str { + "network-sensitive" + } + fn is_available(&self) -> bool { + true + } + fn isolation(&self) -> tinybox_core::IsolationLevel { + tinybox_core::IsolationLevel::Process + } + fn isolation_for(&self, jail: &Jail) -> tinybox_core::IsolationLevel { + if jail.allow_net { + tinybox_core::IsolationLevel::Process + } else { + tinybox_core::IsolationLevel::Kernel + } + } + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { + use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; + ConstraintSupport::NONE + .with(Constraint::Filesystem, Enforcement::Enforced) + .with(Constraint::Network, Enforcement::Enforced) + } + fn spawn(&self, _: &Jail, _: std::process::Command) -> std::io::Result { + Err(std::io::Error::other("preflight fixture never spawns")) + } +} + +#[cfg(unix)] +#[test] +fn process_isolation_is_decided_per_jail_so_shared_network_is_refused() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.path().into()); + let mut config = config(); + config.enforcement = JailEnforcement::Enforced; + config.require_process_isolation = true; + // tinymcp's default: network allowed, isolation required. + config.allow_net = true; + config.allow_subprocess = true; + let backend = NetworkSensitiveBackend; + assert!( + prepare(root.path(), &config, &command, &backend).is_err(), + "a shared network namespace must not satisfy require_process_isolation" + ); + config.allow_net = false; + // Subprocess denial is not enforced by this fixture, so leave it allowed. + assert!(prepare(root.path(), &config, &command, &backend).is_ok()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_symlink_planted_in_the_writable_root_cannot_redirect_a_grant() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let secret = tempfile::tempdir()?; + let backend = Backend { + available: true, + suitable: true, + name: "fixture", + full: true, + }; + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.path().into()); + let planted = root.path().join("vendor"); + std::os::unix::fs::symlink(secret.path(), &planted)?; + + let mut read_only = config(); + read_only + .read_only + .push(planted.to_string_lossy().into_owned()); + assert!(prepare(root.path(), &read_only, &command, &backend).is_err()); + + let mut read_write = config(); + read_write + .read_write + .push(planted.to_string_lossy().into_owned()); + assert!(prepare(root.path(), &read_write, &command, &backend).is_err()); + + // Also when the root is spelled through an alias of its real path. + let alias_holder = tempfile::tempdir()?; + let alias = alias_holder.path().join("alias"); + std::os::unix::fs::symlink(root.path(), &alias)?; + let mut aliased = config(); + aliased + .read_only + .push(planted.to_string_lossy().into_owned()); + assert!(prepare(&alias, &aliased, &command, &backend).is_err()); + + // A symlink outside every writable ancestor is still a fine grant. + let holder = tempfile::tempdir()?; + let tools = holder.path().join("tools"); + std::os::unix::fs::symlink(secret.path(), &tools)?; + let mut outside = config(); + outside.read_only.push(tools.to_string_lossy().into_owned()); + assert!(prepare(root.path(), &outside, &command, &backend).is_ok()); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index 66255ad8..89ea694d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -91,6 +91,9 @@ impl tinybox_jail::JailBackend for Backend { fn is_suitable_for_untrusted_code(&self) -> bool { true } + fn is_suitable_for_untrusted_jail(&self, _: &tinybox_jail::Jail) -> bool { + true + } fn constraint_support(&self) -> tinybox_core::ConstraintSupport { use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; if self.full { diff --git a/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs index fb4de20c..e7afd8e9 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs @@ -38,6 +38,9 @@ impl JailBackend for Recording { fn is_suitable_for_untrusted_code(&self) -> bool { self.suitable } + fn is_suitable_for_untrusted_jail(&self, _: &Jail) -> bool { + self.suitable + } fn constraint_support(&self) -> tinybox_core::ConstraintSupport { use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; ConstraintSupport::NONE diff --git a/scripts/test-features.sh b/scripts/test-features.sh index e8a4237e..552fb701 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -16,6 +16,32 @@ case "${RUNNER_OS:-$(uname -s)}" in export PATH="/usr/bin:$PATH" ;; esac +case "${RUNNER_OS:-$(uname -s)}" in + Linux) + # The bubblewrap jail backend needs `bwrap` and unprivileged user + # namespaces. Install them where we can and make a missing sandbox a test + # failure (TINYBOX_REQUIRE_BWRAP=1) so the real isolation tests can never + # pass by skipping. + sudo_cmd="" + if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then sudo_cmd="sudo"; fi + if ! command -v bwrap >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then + $sudo_cmd apt-get update -qq + $sudo_cmd apt-get install -y -qq bubblewrap + fi + # Ubuntu 24.04 blocks unprivileged user namespaces for unprofiled binaries. + $sudo_cmd sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true + export TINYBOX_REQUIRE_BWRAP=1 + bwrap --version + log="$(mktemp)" + cargo test --locked -p tinybox-jail --test bwrap_isolation --test bwrap_fd_leak -- --nocapture --test-threads=1 2>&1 | tee "$log" + ran="$(grep -c '\[bwrap-real\] .* ok in ' "$log" || true)" + echo "bwrap real isolation tests that ran: $ran" + if [ "$ran" -lt 22 ]; then + echo "expected at least 22 real bwrap tests to run, saw $ran" >&2 + exit 1 + fi + ;; +esac python3 scripts/test-e2e-version.py cargo test --locked --workspace cargo test --locked --workspace --all-features