Cameron Mcloughlinsadbox: Fix bundling (#61873)f620cbc3 months agoHistory
1978 lines · 77.1 KB · Rust
RawDownload
//! Linux sandbox integration built on Bubblewrap (`bwrap`) for filesystem and//! network confinement.//!//! We can use `--bind` and `--ro-bind` (read-only) to bind host filesystem//! paths to paths in the sandbox. If networking is restricted, we also set//! `--unshare-net` to disable *all* network access.//!//! When restricting network access, we://! - set `--unshare-net` - any requests to `example.com` will fail//!   - requests to `localhost` will succeed, but it will be an isolated localhost//!     from the host system.//! - create a unix socket, and mount it in the sandbox with `--bind`//! - run a bridge process inside the sandbox that://!   - listens on `localhost:<port>` and forwards reads/writes to the socket//!   - then, runs the untrusted command//! - on the zed side, we listen to the socket and forward reads/writes to the//!   internal HTTP proxy//!//! If networking is fully blocked or fully allowed, we don't bother with the//! proxy/socket at all (and simply set/unset `--unshare-net`).//!//! This design for networking avoids needing seccomp, a fork/exec dance, and//! eliminates a race condition involving BPF user notifications. use anyhow::{Context as _, Result, anyhow, bail};use std::ffi::{OsStr, OsString};use std::io::{Read, Write};use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream};use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};use std::os::unix::fs::MetadataExt as _;use std::os::unix::net::{UnixListener, UnixStream};use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};use std::path::{Path, PathBuf};use std::process::{Command, Stdio};use std::sync::Arc;use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};use std::thread; /// Re-exec marker for the in-sandbox launcher: it runs inside the sandbox before/// the real command to (a) validate that bwrap bound the writable grants to the/// inodes we captured (the bind-source TOCTOU backstop) and (b) run the/// restricted-network HTTP bridge. See `README.md` for the design.const LAUNCHER_FLAG: &str = "--zed-linux-sandbox-launcher";/// Re-exec marker for the WSL-side helper. This runs *inside WSL* (a Linux/// process) and does what `Sandbox::wrap` + the validation-fd sender do/// in-process on native Linux: capture the writable binds' `O_PATH` fds, stand/// up the validation socket, assemble the bwrap invocation, and spawn it. The/// capture must happen WSL-side because a Windows process holds no Linux fds./// See `README.md`. Shared with the Windows side via `crate::WSL_SANDBOX_HELPER_FLAG`.const WSL_HELPER_FLAG: &str = crate::WSL_SANDBOX_HELPER_FLAG;const WSL_RESOLVE_FLAG: &str = crate::WSL_SANDBOX_RESOLVE_FLAG;/// Sentinel argv token meaning "this optional field is absent".const LAUNCHER_NONE: &str = "-";const PROXY_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox";const VALIDATION_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox-validate";const SANDBOX_SETUP_FAILED_EXIT_CODE: i32 = 126;const PUMP_BUFFER_SIZE: usize = 64 * 1024;/// Upper bound on writable binds validated in a single `SCM_RIGHTS` message,/// kept comfortably below the kernel's per-message fd limit (`SCM_MAX_FD`, 253)./// Exceeding it fails closed rather than silently validating a subset.const MAX_VALIDATED_BINDS: usize = 200; /// Network-access setting for a sandboxed command.////// Mirrors [`crate::macos_seatbelt::NetworkAccess`] so Linux and macOS expose/// the same public shape.#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub enum NetworkAccess {    /// All outbound network blocked (own network namespace).    #[default]    None,    /// Outbound HTTP(S) is available through a loopback proxy port inside the    /// sandbox. The port is bridged to a host-side Unix socket proxy that    /// enforces the hostname allowlist.    LocalhostPort(u16),    /// All outbound network allowed.    All,} /// Per-command relaxations of the default Bubblewrap (Linux) sandbox.////// Mirrors [`crate::macos_seatbelt::SandboxPermissions`].#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub struct SandboxPermissions {    /// Network access policy for the command.    pub network: NetworkAccess,    /// Allow unrestricted filesystem writes.    pub allow_fs_write: bool,} /// The outcome of preparing a Linux sandbox.#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum LauncherStatus {    /// No usable `bwrap` binary was found on `PATH` (or bundled).    BwrapNotFound,    /// The only `bwrap` found is setuid-root, which we refuse to execute.    SetuidRejected,    /// `bwrap` is present but failed to set up the sandbox with our arguments.    SandboxProbeFailed,} impl LauncherStatus {    /// A human-readable explanation suitable for diagnostics.    pub fn describe(self) -> &'static str {        match self {            LauncherStatus::BwrapNotFound => "no usable `bwrap` binary was found on PATH",            LauncherStatus::SetuidRejected => {                "the only available `bwrap` is setuid-root, which Zed refuses to run"            }            LauncherStatus::SandboxProbeFailed => {                "`bwrap` is present but failed to create a sandbox (unprivileged user \                 namespaces may be disabled)"            }        }    }} /// Where a `bwrap` lookup ended up.enum BwrapLocation {    /// A usable, non-setuid `bwrap` binary.    Found(PathBuf),    /// `bwrap` exists but every candidate is setuid-root (which we won't run).    OnlySetuid,    /// No `bwrap` binary was found at all.    NotFound,} fn locate_bwrap() -> BwrapLocation {    let mut saw_setuid = false;    for candidate in candidate_bwrap_paths() {        if !candidate.is_file() {            continue;        }        if is_setuid_root(&candidate) {            saw_setuid = true;            continue;        }        return BwrapLocation::Found(candidate);    }    if saw_setuid {        BwrapLocation::OnlySetuid    } else {        BwrapLocation::NotFound    }} fn candidate_bwrap_paths() -> Vec<PathBuf> {    let mut candidates = Vec::new();    if let Some(system) = system_bwrap_path() {        candidates.push(system);    }    if let Some(bundled) = bundled_bwrap_path() {        candidates.push(bundled);    }    candidates} fn system_bwrap_path() -> Option<PathBuf> {    let path = std::env::var_os("PATH")?;    std::env::split_paths(&path)        .map(|directory| directory.join("bwrap"))        .find(|candidate| candidate.is_file())} fn bundled_bwrap_path() -> Option<PathBuf> {    None} fn is_setuid_root(path: &Path) -> bool {    match std::fs::metadata(path) {        Ok(metadata) => (metadata.mode() & libc::S_ISUID != 0) && metadata.uid() == 0,        Err(_) => false,    }} #[allow(    clippy::disallowed_methods,    reason = "the probe is a short-lived background operation that must block on bwrap")]fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {    // Capture stderr (rather than discarding it) so a failed probe can report    // *why* bwrap refused — the difference between "user namespaces disabled",    // "chdir target missing", "no permission", etc. — instead of a bare    // `SandboxProbeFailed`.    let output = Command::new(bwrap)        .args(bwrap_args)        .arg("--")        .arg("true")        .stdin(Stdio::null())        .stdout(Stdio::null())        .stderr(Stdio::piped())        .output();     match output {        Ok(output) if output.status.success() => true,        Ok(output) => {            log::warn!(                "[sandbox] bwrap probe failed ({}). command: {} {}\nbwrap stderr: {}",                output.status,                bwrap.display(),                bwrap_args.join(" "),                String::from_utf8_lossy(&output.stderr).trim()            );            false        }        Err(error) => {            log::warn!(                "[sandbox] bwrap probe could not be spawned: {error}. command: {} {}",                bwrap.display(),                bwrap_args.join(" ")            );            false        }    }} /// Build the `bwrap` argument list (everything after the `bwrap` program and/// before the trailing `-- <command>`) for the given policy.////// `proxy_socket_path` is the host pathname Unix socket used for/// [`NetworkAccess::LocalhostPort`]. It is bind-mounted to a unique path inside/// the sandbox where the bridge connects to it.pub fn build_bwrap_args(    writable_directories: &[&Path],    protected_paths: &[&Path],    permissions: SandboxPermissions,    cwd: Option<&Path>,    proxy_socket_path: Option<&Path>,) -> Vec<String> {    let proxy_socket_sandbox_path = proxy_socket_path        .filter(|_| matches!(permissions.network, NetworkAccess::LocalhostPort(_)))        .map(|_| unique_proxy_socket_sandbox_path());    build_bwrap_args_with_sandbox_paths(        writable_directories,        protected_paths,        permissions,        cwd,        proxy_socket_path,        proxy_socket_sandbox_path.as_deref(),        None,        None,    )} #[allow(    clippy::too_many_arguments,    reason = "a flat arg list mirrors the bwrap flags this assembles")]fn build_bwrap_args_with_sandbox_paths(    writable_directories: &[&Path],    protected_paths: &[&Path],    permissions: SandboxPermissions,    cwd: Option<&Path>,    proxy_socket_path: Option<&Path>,    proxy_socket_sandbox_path: Option<&Path>,    validation_socket_path: Option<&Path>,    validation_socket_sandbox_path: Option<&Path>,) -> Vec<String> {    let mut args = Vec::new();     let root_bind = if permissions.allow_fs_write {        "--bind"    } else {        "--ro-bind"    };    push_bind(&mut args, root_bind, "/", "/");     args.push("--dev".to_string());    args.push("/dev".to_string());    args.push("--proc".to_string());    args.push("/proc".to_string());     if !permissions.allow_fs_write {        args.push("--tmpfs".to_string());        args.push("/tmp".to_string());         for directory in writable_directories {            // Bind each writable directory at its *exact* path, **verbatim** —            // never re-`canonicalize`d here. The path was already resolved once,            // at capture time, to the pinned inode's current path (`readlink` of            // the `O_PATH` fd); re-resolving it now would reopen the            // time-of-check-to-time-of-use gap a malicious swap exploits. We must            // also never widen to an existing ancestor, so a path that doesn't            // exist is skipped rather than falling back to a parent. Whatever            // inode bwrap actually binds is verified after the mounts by the            // in-sandbox launcher (see `validate_binds`), which fails closed on a            // mismatch.            if !directory.exists() {                continue;            }            let path = directory.to_string_lossy().into_owned();            push_bind(&mut args, "--bind", &path, &path);        }    }     // Protect requested paths by re-binding them read-only *over* any broader rw    // bind (order matters: later binds win). Unlike Seatbelt, bwrap can't deny    // writes while allowing content reads with a policy rule, so a protected path    // is read-only — its contents stay readable but can't be written. A    // not-yet-existing path can't be bound, so it's skipped.    for protected_path in protected_paths {        if !protected_path.exists() {            continue;        }        let path = protected_path.to_string_lossy().into_owned();        push_bind(&mut args, "--ro-bind", &path, &path);    }     for flag in [        "--unshare-user",        "--unshare-ipc",        "--unshare-uts",        "--unshare-pid",        "--unshare-cgroup-try",        "--die-with-parent",    ] {        args.push(flag.to_string());    }     match permissions.network {        NetworkAccess::None => args.push("--unshare-net".to_string()),        NetworkAccess::LocalhostPort(_) => {            args.push("--unshare-net".to_string());            if let Some((proxy_socket_path, proxy_socket_sandbox_path)) =                proxy_socket_path.zip(proxy_socket_sandbox_path)            {                let source = proxy_socket_path.to_string_lossy().into_owned();                let destination = proxy_socket_sandbox_path.to_string_lossy().into_owned();                push_bind(&mut args, "--bind", &source, &destination);            }        }        NetworkAccess::All => {}    }     // The validation socket is filesystem-based, so it works regardless of the    // network policy (an `--unshare-net`'d sandbox can't reach an abstract    // socket, but a bind-mounted one is fine). Bind it after the `/tmp` tmpfs so    // it isn't shadowed by the overlay.    if let Some((source, destination)) = validation_socket_path.zip(validation_socket_sandbox_path)    {        let source = source.to_string_lossy().into_owned();        let destination = destination.to_string_lossy().into_owned();        push_bind(&mut args, "--bind", &source, &destination);    }     if let Some(cwd) = cwd {        args.push("--chdir".to_string());        args.push(cwd.to_string_lossy().into_owned());    }     args} /// A unique destination path for the proxy socket bind mount *inside* the/// sandbox. Each sandboxed command runs in its own mount namespace, so the path/// only needs to be unique enough to avoid colliding with anything a previous/// command in the same namespace lineage may have created; combining the pid/// with a monotonic counter is sufficient and keeps the path predictable for/// diagnostics.fn unique_proxy_socket_sandbox_path() -> PathBuf {    static COUNTER: AtomicU64 = AtomicU64::new(0);    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);    PathBuf::from(format!(        "{PROXY_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock",        std::process::id()    ))} /// In-sandbox bind destination for the validation socket. Each sandboxed command/// runs in its own mount namespace, so this only needs to avoid colliding with/// other binds in the same namespace.fn unique_validation_socket_sandbox_path() -> PathBuf {    static COUNTER: AtomicU64 = AtomicU64::new(0);    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);    PathBuf::from(format!(        "{VALIDATION_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock",        std::process::id()    ))} /// Create a private, owner-only directory for the validation listener socket and/// return `(directory, socket path)`. The socket is a host path (it lives/// outside the sandbox's `/tmp` tmpfs) and is bind-mounted in at/// [`unique_validation_socket_sandbox_path`].////// The socket carries the captured `O_PATH` descriptors over `SCM_RIGHTS`, so/// connect access must be confined to us. Rather than bind in shared `/tmp` and/// `chmod` after (which leaves a window where another user could connect before/// the `chmod` lands), we create a `0700` directory up front: `create` is atomic/// and fails if the path already exists, and `mode(0o700)` makes it/// owner-only from creation, so no other user can even traverse to the socket.fn unique_validation_socket_host_path() -> std::io::Result<(PathBuf, PathBuf)> {    use std::os::unix::fs::DirBuilderExt as _;    static COUNTER: AtomicU64 = AtomicU64::new(0);    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);    let dir = std::env::temp_dir().join(format!(        "zed-sandbox-validate-{}-{counter}",        std::process::id()    ));    // Clear a stale directory left by a previous run that reused this pid; this    // only succeeds for a directory we own, so it can't be used to displace    // another user's squatted path (in which case `create` below fails closed).    let _ = std::fs::remove_dir_all(&dir);    std::fs::DirBuilder::new().mode(0o700).create(&dir)?;    let socket_path = dir.join("validate.sock");    Ok((dir, socket_path))} /// Host endpoint that hands the in-sandbox validator the captured `O_PATH`/// descriptors for the writable binds.////// Runs in-process: `spawn` starts a short-lived background **thread** (never a/// separate process) that listens on an owner-only `AF_UNIX` socket and serves/// the descriptors to **exactly one** client (the in-sandbox validator) via/// `SCM_RIGHTS`, then closes and removes the socket. The validator connects/// once, after bwrap has mounted the binds; `SCM_RIGHTS` keeps the descriptors/// alive in flight, so once the single send completes there is nothing left to/// serve. Holding the descriptors until that send keeps the captured inodes/// pinned (so they can't be recycled) up to the moment they're handed off.////// It is owned by the per-command [`Sandbox`](crate::Sandbox); `Drop` wakes a/// still-blocked accept and removes the socket and its private directory (a/// no-op if the thread already tore them down). The socket is bind-mounted into/// the sandbox at [`Self::sandbox_socket_path`].pub(crate) struct ValidationFdSender {    host_socket_dir: PathBuf,    host_socket_path: PathBuf,    sandbox_socket_path: PathBuf,    shutdown: Arc<AtomicBool>,} impl ValidationFdSender {    /// Start serving `fds` (one per writable bind, in bind order). The caller    /// must pass the same order to the launcher so each fd lines up with its    /// bind-destination path.    pub(crate) fn spawn(fds: Vec<OwnedFd>) -> std::io::Result<Self> {        use std::os::unix::fs::PermissionsExt as _;         if fds.len() > MAX_VALIDATED_BINDS {            return Err(std::io::Error::other(format!(                "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",                fds.len()            )));        }        let (host_socket_dir, host_socket_path) = unique_validation_socket_host_path()?;        let listener = UnixListener::bind(&host_socket_path)?;        // The enclosing directory is already `0700`, so this is defense in depth:        // connect permission on an `AF_UNIX` socket is governed by write        // permission on the socket file, so keep it owner-only too.        std::fs::set_permissions(&host_socket_path, std::fs::Permissions::from_mode(0o600))?;        let sandbox_socket_path = unique_validation_socket_sandbox_path();        let shutdown = Arc::new(AtomicBool::new(false));         // We use std threading APIs here because this code is run from both the        // Linux zed binary and also the WSL helper, which does not have a GPUI        // runtime.        thread::Builder::new()            .name("zed-sandbox-validation".to_string())            .spawn({                let shutdown = shutdown.clone();                let host_socket_path = host_socket_path.clone();                move || {                    // `fds` is held until the single send below, keeping the                    // pinned inodes alive until they're handed to the validator.                    let raw_fds: Vec<RawFd> = fds.iter().map(|fd| fd.as_raw_fd()).collect();                    // Serve exactly one client: the in-sandbox validator connects                    // once. A second connection (e.g. the wake from `Drop`) is                    // distinguished by the `shutdown` flag and never served.                    match listener.accept() {                        Ok((stream, _)) => {                            if !shutdown.load(Ordering::SeqCst) {                                if let Err(error) = send_fds(&stream, &raw_fds) {                                    log::warn!("[sandbox] failed to send validation fds: {error}");                                }                            }                        }                        Err(error) => {                            if !shutdown.load(Ordering::SeqCst) {                                log::warn!("[sandbox] sandbox validation accept failed: {error}");                            }                        }                    }                    drop(fds);                    // Close the listener and remove the socket so it can't be                    // connected to again. The directory is removed by `Drop`.                    drop(listener);                    let _ = std::fs::remove_file(&host_socket_path);                }            })?;         Ok(Self {            host_socket_dir,            host_socket_path,            sandbox_socket_path,            shutdown,        })    }     pub(crate) fn host_socket_path(&self) -> &Path {        &self.host_socket_path

Lines 1–500 of 1978.