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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/tinybox-bus/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ pub struct NativeJailConfig {
/// provider that reads allow-by-default). A path that does not exist yet is
/// kept and resolved again just before spawn, except that the bubblewrap
/// backend refuses a missing denial inside one of the jail's mounts: it
/// would have to create the path to mask it. Added in contract 1.20; empty
/// by default.
/// would have to create the path to mask it. A denial inside a grant
/// covers the path as it exists when the jail starts; a host that replaces
/// it during the run exposes the new file (see the bubblewrap backend's
/// known gaps). Added in contract 1.20; empty by default.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny_read: Vec<String>,
/// Optional hard resource limits; omitted for compatibility with older requests.
Expand Down
15 changes: 12 additions & 3 deletions crates/tinybox-jail/src/bwrap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,18 @@
//! `--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.
//! - `deny_read` paths must exist when the jail starts. A missing one inside a
//! mount is refused, since a path created later would not be covered; one
//! nested more than a level inside a writable grant is refused too.
//! - **A mask lasts only as long as the path it covers.** A denial is a mount
//! over that path's directory entry, and the kernel drops a mount from other
//! mount namespaces when its entry is unlinked or renamed over. So if a
//! host process replaces a denied path inside a grant while the jail runs
//! (an atomic rename, as credential helpers and editors write), the jail
//! sees the new file. Shown on bubblewrap 0.9.0 and Linux 7.0 for a file
//! and a directory. bwrap cannot prevent it. A host should deny a credential
//! store outside every grant (it is then never mounted at all), or deny its
//! parent directory, rather than mask a file it rewrites.
//! - Landlock is not layered inside: the kernel denies `mount` and
//! `pivot_root` to a Landlock-restricted thread, which would break `bwrap`'s
//! own setup.
Expand Down
4 changes: 3 additions & 1 deletion crates/tinybox-jail/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ pub struct Jail {
/// [`carves_denials`](JailBackend::carves_denials) subtracts them from
/// grants; allow-list backends deny paths outside grants by default. The
/// native module rejects overlaps that an allow-list backend cannot enforce.
/// Missing paths are retained so backend rules can cover later creation.
/// Missing paths are retained here; backends decide: Landlock and Seatbelt
/// rules cover later creation, while bubblewrap refuses a missing path
/// inside a mount.
pub deny_read: Vec<PathBuf>,
/// File or directory names the child may neither read nor write wherever
/// they appear. Only a backend that
Expand Down
35 changes: 34 additions & 1 deletion crates/tinybox-linux/src/sandbox/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,33 @@ fn validate_native_jail_command(request: &NativeJailCommand) -> std::io::Result<
));
}

refuse_missing_denials_inside_mounts(request)
}

/// A `deny_read` path must exist when it lies inside one of the jail's mounts:
/// masking a missing one (`--ro-bind /dev/null <path>`) makes bwrap create it
/// on the host, an empty file in the caller's tree, and a path created after
/// the jail starts would not be covered anyway. One outside every mount is
/// never visible in the sandbox, so it is skipped when the argv is built.
fn refuse_missing_denials_inside_mounts(request: &NativeJailCommand) -> std::io::Result<()> {
let mounts: Vec<&Path> = std::iter::once(&request.root)
.chain(&request.read_only)
.chain(&request.read_write)
.map(PathBuf::as_path)
.collect();
for denied in &request.deny_read {
if denied.symlink_metadata().is_err()
&& mounts.iter().any(|mount| denied.starts_with(mount))
{
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"deny_read path {} must exist before starting the jail",
denied.display()
),
));
}
}
Ok(())
}

Expand Down Expand Up @@ -664,7 +691,13 @@ fn native_bwrap_argv(
destination.display().to_string(),
]);
}
for denied in &request.deny_read {
// A missing denial reaching here lies outside every mount (see
// `refuse_missing_denials_inside_mounts`): nothing to mask.
for denied in request
.deny_read
.iter()
.filter(|denied| denied.symlink_metadata().is_ok())
{
if denied.is_dir() {
argv.extend(["--tmpfs".into(), denied.display().to_string()]);
} else {
Expand Down
49 changes: 48 additions & 1 deletion crates/tinybox-linux/src/sandbox/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use std::collections::BTreeMap;
#[cfg(target_os = "linux")]
use std::os::unix::fs::PermissionsExt as _;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};

use async_trait::async_trait;
Expand Down Expand Up @@ -705,6 +705,53 @@ fn native_jail_command_refuses_invalid_resource_and_isolation_requests()
Ok(())
}

#[test]
#[cfg(target_os = "linux")]
fn a_missing_denial_inside_a_mount_is_refused_and_one_outside_is_skipped()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let cwd = root.path().join("work");
std::fs::create_dir(&cwd)?;
let elsewhere = tempfile::tempdir()?;
let inside = root.path().join("credentials.json");
let outside = elsewhere.path().join("not-mounted");
let request = |deny_read: Vec<PathBuf>| crate::NativeJailCommand {
root: root.path().to_path_buf(),
cwd: cwd.clone(),
read_only: Vec::new(),
read_write: Vec::new(),
deny_read,
allow_net: false,
allow_subprocess: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high critique likely

Add the missing disable_userns field to the test request

`NativeJailCommand` declares a `disable_userns` field that `native_bwrap_argv` reads (`if request.disable_userns {`), and the only in-repo constructor in `crates/tinybox-module/src/tinybus_module/native_jail.rs` sets it (`disable_userns: bwrap_disables_userns()`). The struct is a plain `#[derive(Debug, Clone)]` type with no `Default`, so a struct literal must name every field: this literal in `a_missing_denial_inside_a_mount_is_refused_and_one_outside_is_skipped` omits `disable_userns`, and `cargo test -p tinybox-linux` fails with E0063 (missing field `disable_userns` in initializer of `NativeJailCommand`). Add the field so the file compiles.

[RULE] missing-struct-field ·

cpu_millis: Some(1000),
memory_bytes: None,
pids_max: None,
argv: vec!["/bin/true".into()],
env: BTreeMap::new(),
};
let systemd_run = crate::trusted_systemd_run_program()?;
let build = |deny_read| {
crate::native_jail_argv_with_programs(
&request(deny_read),
&systemd_run,
Path::new("/bin/true"),
)
};

// Masking it would make bwrap create an empty file in the caller's tree.
let kind = build(vec![inside.clone()]).err().map(|error| error.kind());
assert_eq!(kind, Some(std::io::ErrorKind::NotFound));
assert!(!inside.exists(), "nothing may be created on the host");

// Outside every mount the sandbox never shows it: nothing to mask.
let argv = build(vec![outside.clone()])?;
assert!(
!argv.iter().any(|part| Path::new(part) == outside),
"{argv:?}"
);
Ok(())
}

#[tokio::test]
async fn without_limits_there_is_no_scope_at_all() -> Result<()> {
let (sandbox, host) = sandbox();
Expand Down
Loading