Large change to sandboxing: - fixes a nasty TOCTOU relating to a symlink swap attack, documented in the `sandboxing/README.md` - Adds UI and restrictions when in an untrusted workspace - Adds tests for (soon to be removed) git support --- Release Notes: - N/A or Added/Fixed/Improved ...
532 lines
21 KiB
Rust
532 lines
21 KiB
Rust
//! Test binary used exclusively by the NixOS integration tests for the
|
|
//! Bubblewrap sandbox at `nix/tests/sandboxing`. See the comment there.
|
|
//!
|
|
//! It drives the sandbox crate's *public* API only (`Sandbox`, `SandboxPolicy`,
|
|
//! …) — never platform internals — so it doubles as a check that the public API
|
|
//! is sufficient to express and enforce the policies the agent needs.
|
|
//!
|
|
//! The list of checks to run is *data*, declared by the Nix test and handed to
|
|
//! this binary as a JSON file (path in `ZED_SANDBOX_CHECKS`). Each check
|
|
//! describes a sandbox policy, an operation to attempt under that policy, and
|
|
//! the expected outcome; this binary executes each and asserts the result.
|
|
|
|
#![allow(
|
|
clippy::disallowed_methods,
|
|
reason = "a single-threaded test helper that intentionally blocks on child processes"
|
|
)]
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn main() {
|
|
eprintln!("bwrap_test_helper is only supported on Linux");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn main() {
|
|
imp::main();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod imp {
|
|
use std::io::{Read as _, Write as _};
|
|
use std::net::TcpStream;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use sandbox::{
|
|
CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
|
|
SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
/// Internal subcommand: round-trip a byte through the echo server at the
|
|
/// given `host:port`, honoring `HTTP_PROXY` when set (the restricted-network
|
|
/// case routes through the sandbox proxy via HTTP CONNECT). Exits 0 on a
|
|
/// successful round-trip, non-zero otherwise. Run *inside* the sandbox.
|
|
const SUBCOMMAND_ECHO_CHECK: &str = "__echo_check";
|
|
|
|
/// Default port for echo targets given as a bare hostname (e.g. `echo1`).
|
|
const DEFAULT_ECHO_PORT: &str = "7000";
|
|
|
|
pub fn main() {
|
|
// If we were re-exec'd as the restricted-network bridge, this starts the
|
|
// bridge and execs the wrapped command without returning.
|
|
sandbox::run_sandbox_launcher_if_invoked();
|
|
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let result = match args.get(1).map(String::as_str) {
|
|
Some(SUBCOMMAND_ECHO_CHECK) => run_echo_check(args.get(2).map(String::as_str)),
|
|
_ => run_checks(),
|
|
};
|
|
|
|
if let Err(error) = result {
|
|
eprintln!("[sandbox_test]: FAILED: {error:#}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// Filesystem policy as declared in a check (`fs` field).
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum FsMode {
|
|
/// Reads allowed everywhere; writes confined to `writablePaths`.
|
|
#[default]
|
|
Restricted,
|
|
/// Writes allowed anywhere.
|
|
Unrestricted,
|
|
}
|
|
|
|
/// Network policy as declared in a check (`networkAccess` field).
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum NetMode {
|
|
/// All outbound network blocked.
|
|
#[default]
|
|
Blocked,
|
|
/// All outbound network allowed.
|
|
Unrestricted,
|
|
/// Outbound HTTP(S) allowed only to `allowedDomains`.
|
|
Restricted,
|
|
}
|
|
|
|
/// One declarative check: a sandbox policy, an operation, and the expected
|
|
/// result. Deserialized from the JSON the Nix test produces.
|
|
///
|
|
/// Exactly one operation field (`read`, `write`, `network`, or `canCreate`)
|
|
/// must be set. Policy fields default to the most-confined policy
|
|
/// (restricted filesystem with no writable paths, blocked network).
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct Check {
|
|
/// Optional human label; falls back to an auto-generated description.
|
|
#[serde(default)]
|
|
name: Option<String>,
|
|
|
|
// ---- policy ----
|
|
#[serde(default)]
|
|
fs: FsMode,
|
|
#[serde(default)]
|
|
writable_paths: Vec<String>,
|
|
#[serde(default)]
|
|
network_access: NetMode,
|
|
#[serde(default)]
|
|
allowed_domains: Vec<String>,
|
|
/// `.git` directories to protect (contents read-only on Linux). Selects a
|
|
/// `Denied` Git policy. Mutually exclusive with `gitAllowed`.
|
|
#[serde(default)]
|
|
git_disabled: Vec<String>,
|
|
/// `.git` directories to make writable. Selects an `Allowed` Git policy.
|
|
/// Mutually exclusive with `gitDisabled`.
|
|
#[serde(default)]
|
|
git_allowed: Vec<String>,
|
|
|
|
// ---- operation (exactly one) ----
|
|
/// Read this host path from inside the sandbox.
|
|
#[serde(default)]
|
|
read: Option<String>,
|
|
/// Write to this host path from inside the sandbox.
|
|
#[serde(default)]
|
|
write: Option<String>,
|
|
/// Connect to this echo host (`hostname` or `hostname:port`) from inside
|
|
/// the sandbox.
|
|
#[serde(default)]
|
|
network: Option<String>,
|
|
/// Assert that `Sandbox::can_create` for this policy matches the value:
|
|
/// `true` => the sandbox can be created, `false` => it cannot.
|
|
#[serde(default)]
|
|
can_create: Option<bool>,
|
|
|
|
// ---- expectation ----
|
|
/// Expected outcome for `read` / `write` / `network`.
|
|
#[serde(default)]
|
|
succeeds: Option<bool>,
|
|
/// Expected error kind for `canCreate = false`
|
|
/// (`bwrap_not_found` | `setuid_rejected` | `probe_failed`). When
|
|
/// omitted, any creation failure is accepted.
|
|
#[serde(default)]
|
|
error: Option<String>,
|
|
}
|
|
|
|
fn run_checks() -> Result<()> {
|
|
// The restricted-network proxy runs in *this* process and the echo
|
|
// servers live on the VM's private network, which the proxy's
|
|
// DNS-rebinding protection would otherwise reject. The proxy only honors
|
|
// this var when built with `http_proxy/nixos-integration-tests` (pulled
|
|
// in by `sandbox/nixos-test`, which this binary requires), so it has no
|
|
// effect in a real Zed build.
|
|
// SAFETY: single-threaded at this point.
|
|
unsafe {
|
|
std::env::set_var("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS", "1");
|
|
}
|
|
|
|
let checks_path =
|
|
std::env::var("ZED_SANDBOX_CHECKS").context("ZED_SANDBOX_CHECKS must be set")?;
|
|
let raw = std::fs::read_to_string(&checks_path)
|
|
.with_context(|| format!("failed to read checks file {checks_path}"))?;
|
|
let specs: Vec<Check> =
|
|
serde_json::from_str(&raw).context("failed to parse checks JSON")?;
|
|
let echo_port =
|
|
std::env::var("ZED_TEST_ECHO_PORT").unwrap_or_else(|_| DEFAULT_ECHO_PORT.to_string());
|
|
|
|
println!("[sandbox_test]: running {} check(s)", specs.len());
|
|
|
|
let mut checks = Checks::new();
|
|
for spec in &specs {
|
|
run_check(spec, &echo_port, &mut checks)?;
|
|
}
|
|
checks.finish()
|
|
}
|
|
|
|
fn policy_of(check: &Check) -> Result<SandboxPolicy> {
|
|
let fs = match check.fs {
|
|
FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
|
|
FsMode::Restricted => {
|
|
let mut writable_paths = Vec::new();
|
|
for path in &check.writable_paths {
|
|
// Mirror production (`acp_thread::SandboxWrap::to_policy`):
|
|
// the directory must exist before its inode can be pinned, so
|
|
// create it up front, then capture it.
|
|
std::fs::create_dir_all(path)
|
|
.with_context(|| format!("failed to create writable path {path}"))?;
|
|
writable_paths.push(
|
|
HostFilesystemLocation::new(path)
|
|
.with_context(|| format!("failed to capture writable path {path}"))?,
|
|
);
|
|
}
|
|
SandboxFsPolicy::Restricted { writable_paths }
|
|
}
|
|
};
|
|
let network = match check.network_access {
|
|
NetMode::Unrestricted => SandboxNetPolicy::Unrestricted,
|
|
NetMode::Blocked => SandboxNetPolicy::Blocked,
|
|
NetMode::Restricted => SandboxNetPolicy::Restricted {
|
|
allowed_domains: check.allowed_domains.clone(),
|
|
},
|
|
};
|
|
// `gitAllowed` and `gitDisabled` are mutually exclusive; `gitAllowed`
|
|
// wins if both are (mistakenly) set. With neither, the default protects
|
|
// an empty set of dirs, which is a no-op.
|
|
let git = if !check.git_allowed.is_empty() {
|
|
GitSandboxPolicy::Allowed {
|
|
git_dirs: capture_git_dirs(&check.git_allowed),
|
|
}
|
|
} else if !check.git_disabled.is_empty() {
|
|
GitSandboxPolicy::Denied {
|
|
git_dirs: capture_git_dirs(&check.git_disabled),
|
|
}
|
|
} else {
|
|
GitSandboxPolicy::default()
|
|
};
|
|
Ok(SandboxPolicy { fs, network, git })
|
|
}
|
|
|
|
/// Capture each already-existing `.git` directory, mirroring production's
|
|
/// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a `.git`
|
|
/// that does not yet exist can't be pinned and is simply skipped (the
|
|
/// documented Linux gap). Unlike writable paths, these are never created
|
|
/// here — whether one exists is exactly what several checks turn on.
|
|
fn capture_git_dirs(paths: &[String]) -> Vec<HostFilesystemLocation> {
|
|
paths
|
|
.iter()
|
|
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
|
.collect()
|
|
}
|
|
|
|
fn describe(check: &Check) -> String {
|
|
if let Some(name) = &check.name {
|
|
return name.clone();
|
|
}
|
|
let git = if !check.git_allowed.is_empty() {
|
|
format!(",git_allowed={:?}", check.git_allowed)
|
|
} else if !check.git_disabled.is_empty() {
|
|
format!(",git_disabled={:?}", check.git_disabled)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let policy = format!("fs={:?},net={:?}{git}", check.fs, check.network_access);
|
|
let op = if let Some(path) = &check.read {
|
|
format!("read {path}")
|
|
} else if let Some(path) = &check.write {
|
|
format!("write {path}")
|
|
} else if let Some(host) = &check.network {
|
|
format!("network {host}")
|
|
} else if let Some(expected) = check.can_create {
|
|
format!("can_create == {expected}")
|
|
} else {
|
|
"<no operation>".to_string()
|
|
};
|
|
format!("[{policy}] {op}")
|
|
}
|
|
|
|
fn run_check(check: &Check, echo_port: &str, checks: &mut Checks) -> Result<()> {
|
|
let label = describe(check);
|
|
|
|
if let Some(expect_can_create) = check.can_create {
|
|
let policy = policy_of(check)?;
|
|
let outcome = Sandbox::can_create(&policy);
|
|
let passed = match (&outcome, expect_can_create) {
|
|
(Ok(()), true) => true,
|
|
(Ok(()), false) => false,
|
|
(Err(_), true) => false,
|
|
(Err(error), false) => check
|
|
.error
|
|
.as_deref()
|
|
.map(|expected| error_matches(error, expected))
|
|
.unwrap_or(true),
|
|
};
|
|
checks.check(&format!("{label} (got {outcome:?})"), passed);
|
|
return Ok(());
|
|
}
|
|
|
|
let succeeds = check
|
|
.succeeds
|
|
.with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
|
|
|
|
let actual = if let Some(path) = &check.read {
|
|
run_read(check, path)?
|
|
} else if let Some(path) = &check.write {
|
|
run_write(check, path)?
|
|
} else if let Some(host) = &check.network {
|
|
run_network(check, host, echo_port)?
|
|
} else {
|
|
bail!("check {label:?} has no operation");
|
|
};
|
|
|
|
checks.check(&label, actual == succeeds);
|
|
Ok(())
|
|
}
|
|
|
|
/// Seed a host file, then `cat` it from inside the sandbox. Reads are always
|
|
/// granted (root is bound read-only), so this proves the sandbox doesn't
|
|
/// *block* reads of existing host files.
|
|
fn run_read(check: &Check, path: &str) -> Result<bool> {
|
|
let path = Path::new(path);
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create parent of {}", path.display()))?;
|
|
}
|
|
std::fs::write(path, b"sandbox-test\n")
|
|
.with_context(|| format!("failed to seed readable file {}", path.display()))?;
|
|
|
|
// Build the policy only after the fixtures exist: capturing a
|
|
// `HostFilesystemLocation` pins the inode, so the path must be present.
|
|
let policy = policy_of(check)?;
|
|
let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
|
|
run_command(
|
|
&mut sandbox,
|
|
"sh",
|
|
&["-c", &format!("cat {}", shell_quote(path))],
|
|
)
|
|
}
|
|
|
|
/// Attempt to write a host file from inside the sandbox. "Succeeded" means
|
|
/// the command exited 0 *and* the bytes actually landed on the host file —
|
|
/// a write that only hits the sandbox's ephemeral tmpfs counts as blocked,
|
|
/// since it never escaped the sandbox.
|
|
fn run_write(check: &Check, path: &str) -> Result<bool> {
|
|
let path = Path::new(path);
|
|
if let Some(parent) = path.parent() {
|
|
// Create the parent on the host so the only thing under test is the
|
|
// sandbox's write permission, not a missing directory. This also
|
|
// makes a `.git` parent exist before the policy captures it.
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create parent of {}", path.display()))?;
|
|
}
|
|
// Start from a clean slate so `exists()` afterwards is meaningful.
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
// Build the policy only after the fixtures exist: capturing a
|
|
// `HostFilesystemLocation` pins the inode, so the path must be present.
|
|
let policy = policy_of(check)?;
|
|
let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
|
|
let command_ok = run_command(
|
|
&mut sandbox,
|
|
"sh",
|
|
&[
|
|
"-c",
|
|
&format!("printf sandbox-test > {}", shell_quote(path)),
|
|
],
|
|
)?;
|
|
Ok(command_ok && path.exists())
|
|
}
|
|
|
|
/// Connect to `host` (`hostname` or `hostname:port`) from inside the
|
|
/// sandbox via the `__echo_check` subcommand, which honors `HTTP_PROXY` for
|
|
/// the restricted-network case.
|
|
fn run_network(check: &Check, host: &str, echo_port: &str) -> Result<bool> {
|
|
let target = if host.contains(':') {
|
|
host.to_string()
|
|
} else {
|
|
format!("{host}:{echo_port}")
|
|
};
|
|
let exe = current_exe_str()?;
|
|
let policy = policy_of(check)?;
|
|
let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
|
|
run_command(&mut sandbox, &exe, &[SUBCOMMAND_ECHO_CHECK, &target])
|
|
}
|
|
|
|
fn error_matches(error: &SandboxError, expected: &str) -> bool {
|
|
matches!(
|
|
(error, expected),
|
|
(SandboxError::BwrapNotFound, "bwrap_not_found")
|
|
| (SandboxError::BwrapSetuidRejected, "setuid_rejected")
|
|
| (SandboxError::SandboxProbeFailed, "probe_failed")
|
|
)
|
|
}
|
|
|
|
fn sandbox_err(error: SandboxError) -> anyhow::Error {
|
|
anyhow::anyhow!("failed to create sandbox: {error}")
|
|
}
|
|
|
|
/// Inner command: prove (or fail to prove) reachability of an echo server.
|
|
///
|
|
/// With `HTTP_PROXY` set we open an HTTP `CONNECT` tunnel through the proxy
|
|
/// and then echo a byte; a policy denial shows up as a non-200 status. With
|
|
/// no proxy we connect directly. Either way, a clean round-trip means the
|
|
/// destination was reachable under the active network policy.
|
|
fn run_echo_check(target: Option<&str>) -> Result<()> {
|
|
let target = target.context("echo check requires a host:port argument")?;
|
|
let proxy = std::env::var("HTTP_PROXY")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.or_else(|| {
|
|
std::env::var("http_proxy")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
});
|
|
|
|
let mut stream = match proxy {
|
|
Some(proxy_url) => {
|
|
let proxy_addr = proxy_url
|
|
.trim()
|
|
.strip_prefix("http://")
|
|
.unwrap_or(proxy_url.trim())
|
|
.trim_end_matches('/')
|
|
.to_string();
|
|
let mut stream = TcpStream::connect(&proxy_addr)
|
|
.with_context(|| format!("failed to connect to proxy {proxy_addr}"))?;
|
|
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
write!(
|
|
stream,
|
|
"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"
|
|
)
|
|
.context("failed to send CONNECT to proxy")?;
|
|
let status = read_status_line(&mut stream)?;
|
|
if !status.contains(" 200") {
|
|
bail!("proxy refused CONNECT to {target}: {status:?}");
|
|
}
|
|
stream
|
|
}
|
|
None => {
|
|
let stream = TcpStream::connect(target)
|
|
.with_context(|| format!("failed to connect to {target}"))?;
|
|
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
stream
|
|
}
|
|
};
|
|
|
|
stream
|
|
.write_all(b"ping\n")
|
|
.context("failed to write to echo server")?;
|
|
let mut buffer = [0u8; 32];
|
|
let read = stream
|
|
.read(&mut buffer)
|
|
.context("failed to read from echo server")?;
|
|
let echoed = String::from_utf8_lossy(&buffer[..read]);
|
|
if echoed.contains("ping") {
|
|
Ok(())
|
|
} else {
|
|
bail!("echo server returned unexpected data: {echoed:?}");
|
|
}
|
|
}
|
|
|
|
/// Read an HTTP status line (up to the first CRLF), then drain the rest of
|
|
/// the header block (up to the blank line) so the stream is positioned at
|
|
/// the tunneled body.
|
|
fn read_status_line(stream: &mut TcpStream) -> Result<String> {
|
|
let mut header = Vec::new();
|
|
let mut byte = [0u8; 1];
|
|
loop {
|
|
let read = stream
|
|
.read(&mut byte)
|
|
.context("failed reading proxy reply")?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
header.push(byte[0]);
|
|
if header.ends_with(b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
if header.len() > 64 * 1024 {
|
|
bail!("proxy reply headers too large");
|
|
}
|
|
}
|
|
let text = String::from_utf8_lossy(&header);
|
|
Ok(text.lines().next().unwrap_or_default().to_string())
|
|
}
|
|
|
|
/// Tracks assertion results so we can report a summary and a single exit code.
|
|
struct Checks {
|
|
passed: usize,
|
|
failed: usize,
|
|
}
|
|
|
|
impl Checks {
|
|
fn new() -> Self {
|
|
Self {
|
|
passed: 0,
|
|
failed: 0,
|
|
}
|
|
}
|
|
|
|
fn check(&mut self, description: &str, condition: bool) {
|
|
if condition {
|
|
self.passed += 1;
|
|
println!("[sandbox_test]: PASS: {description}");
|
|
} else {
|
|
self.failed += 1;
|
|
println!("[sandbox_test]: FAIL: {description}");
|
|
}
|
|
}
|
|
|
|
fn finish(self) -> Result<()> {
|
|
println!(
|
|
"[sandbox_test]: summary: {} passed, {} failed",
|
|
self.passed, self.failed
|
|
);
|
|
if self.failed > 0 {
|
|
bail!("{} sandbox assertion(s) failed", self.failed);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Wrap and run a command inside `sandbox`, returning whether it exited 0.
|
|
fn run_command(sandbox: &mut Sandbox, program: &str, args: &[&str]) -> Result<bool> {
|
|
let command = CommandAndArgs {
|
|
program: program.to_string(),
|
|
args: args.iter().map(|arg| arg.to_string()).collect(),
|
|
env: Default::default(),
|
|
cwd: None,
|
|
};
|
|
let output = futures::executor::block_on(sandbox.execute(&command))
|
|
.map_err(|error| anyhow::anyhow!("failed to run sandboxed command: {error}"))?;
|
|
Ok(output.status.success())
|
|
}
|
|
|
|
fn current_exe_str() -> Result<String> {
|
|
Ok(std::env::current_exe()
|
|
.context("failed to resolve current executable")?
|
|
.to_str()
|
|
.context("current executable path is not valid UTF-8")?
|
|
.to_string())
|
|
}
|
|
|
|
/// Single-quote a path for safe interpolation into an `sh -c` script.
|
|
fn shell_quote(path: &Path) -> String {
|
|
let raw = path.to_string_lossy();
|
|
format!("'{}'", raw.replace('\'', "'\\''"))
|
|
}
|
|
}
|