Skip to content

Commit e0db01e

Browse files
authored
fix(sandbox): preserve ownership for existing read_write paths (#827)
Closes #783 Only chown read_write paths that the supervisor creates at startup, and leave pre-existing image paths with their original ownership. Add sandbox tests for creation, symlink rejection, and existing-path ownership preservation, and update architecture docs to match the new behavior. Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent 0bf4216 commit e0db01e

File tree

3 files changed

+147
-27
lines changed

3 files changed

+147
-27
lines changed

architecture/sandbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ flowchart TD
8888

8989
3. **Binary identity cache**: If OPA engine is active, create `Arc<BinaryIdentityCache::new()>` for SHA256 TOFU enforcement.
9090

91-
4. **Filesystem preparation** (`prepare_filesystem()`): For each path in `filesystem.read_write`, create the directory if it does not exist and `chown` to the configured `run_as_user`/`run_as_group`. Runs as the supervisor (root) before forking.
91+
4. **Filesystem preparation** (`prepare_filesystem()`): For each path in `filesystem.read_write`, reject symlinks, create the directory if it does not exist, and `chown` only newly-created paths to the configured `run_as_user`/`run_as_group`. Pre-existing paths keep the image-defined ownership. Runs as the supervisor (root) before forking.
9292

9393
5. **TLS state for L7 inspection** (proxy mode only):
9494
- Generate ephemeral CA via `SandboxCa::generate()` using `rcgen`

architecture/security-policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ Controls which filesystem paths the sandboxed process can access. Enforced via L
322322

323323
**Enforcement mapping**: Each path becomes a Landlock `PathBeneath` rule. Read-only paths receive `AccessFs::from_read(ABI::V2)` permissions. Read-write paths receive `AccessFs::from_all(ABI::V2)` permissions (read, write, execute, create, delete, rename). All other paths are denied by the Landlock ruleset.
324324

325-
**Filesystem preparation**: Before the child process spawns, the supervisor creates any `read_write` directories that do not exist and sets their ownership to `process.run_as_user`:`process.run_as_group` via `chown()`. See `crates/openshell-sandbox/src/lib.rs` -- `prepare_filesystem()`.
325+
**Filesystem preparation**: Before the child process spawns, the supervisor rejects symlinked `read_write` paths, creates any missing `read_write` directories, and sets ownership via `chown()` only on paths it created. Pre-existing image paths keep their existing ownership. See `crates/openshell-sandbox/src/lib.rs` -- `prepare_filesystem()`.
326326

327327
**Working directory**: When `include_workdir` is `true` and a `--workdir` is specified, the working directory path is appended to `read_write` if not already present. See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `apply()`.
328328

crates/openshell-sandbox/src/lib.rs

Lines changed: 145 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,11 +1764,44 @@ fn validate_sandbox_user(policy: &SandboxPolicy) -> Result<()> {
17641764
Ok(())
17651765
}
17661766

1767+
/// Prepare a `read_write` path for the sandboxed process.
1768+
///
1769+
/// Returns `true` when the path was created by the supervisor and therefore
1770+
/// still needs to be chowned to the sandbox user/group. Existing paths keep
1771+
/// their image-defined ownership.
1772+
#[cfg(unix)]
1773+
fn prepare_read_write_path(path: &std::path::Path) -> Result<bool> {
1774+
// SECURITY: use symlink_metadata (lstat) to inspect each path *before*
1775+
// calling chown. chown follows symlinks, so a malicious container image
1776+
// could place a symlink (e.g. /sandbox -> /etc/shadow) to trick the
1777+
// root supervisor into transferring ownership of arbitrary files.
1778+
// The TOCTOU window between lstat and chown is not exploitable because
1779+
// no untrusted process is running yet (the child has not been forked).
1780+
if let Ok(meta) = std::fs::symlink_metadata(path) {
1781+
if meta.file_type().is_symlink() {
1782+
return Err(miette::miette!(
1783+
"read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)",
1784+
path.display()
1785+
));
1786+
}
1787+
1788+
debug!(
1789+
path = %path.display(),
1790+
"Preserving ownership for existing read_write path"
1791+
);
1792+
Ok(false)
1793+
} else {
1794+
debug!(path = %path.display(), "Creating read_write directory");
1795+
std::fs::create_dir_all(path).into_diagnostic()?;
1796+
Ok(true)
1797+
}
1798+
}
1799+
17671800
/// Prepare filesystem for the sandboxed process.
17681801
///
17691802
/// Creates `read_write` directories if they don't exist and sets ownership
1770-
/// to the configured sandbox user/group. This runs as the supervisor (root)
1771-
/// before forking the child process.
1803+
/// on newly-created paths to the configured sandbox user/group. This runs as
1804+
/// the supervisor (root) before forking the child process.
17721805
#[cfg(unix)]
17731806
fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> {
17741807
use nix::unistd::{Group, User, chown};
@@ -1810,31 +1843,17 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> {
18101843
None
18111844
};
18121845

1813-
// Create and chown each read_write path.
1814-
//
1815-
// SECURITY: use symlink_metadata (lstat) to inspect each path *before*
1816-
// calling chown. chown follows symlinks, so a malicious container image
1817-
// could place a symlink (e.g. /sandbox -> /etc/shadow) to trick the
1818-
// root supervisor into transferring ownership of arbitrary files.
1819-
// The TOCTOU window between lstat and chown is not exploitable because
1820-
// no untrusted process is running yet (the child has not been forked).
1846+
// Create missing read_write paths and only chown the ones we created.
18211847
for path in &policy.filesystem.read_write {
1822-
// Check for symlinks before touching the path. Character/block devices
1823-
// (e.g. /dev/null) are legitimate read_write entries and must be allowed.
1824-
if let Ok(meta) = std::fs::symlink_metadata(path) {
1825-
if meta.file_type().is_symlink() {
1826-
return Err(miette::miette!(
1827-
"read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)",
1828-
path.display()
1829-
));
1830-
}
1831-
} else {
1832-
debug!(path = %path.display(), "Creating read_write directory");
1833-
std::fs::create_dir_all(path).into_diagnostic()?;
1848+
if prepare_read_write_path(path)? {
1849+
debug!(
1850+
path = %path.display(),
1851+
?uid,
1852+
?gid,
1853+
"Setting ownership on newly created read_write path"
1854+
);
1855+
chown(path, uid, gid).into_diagnostic()?;
18341856
}
1835-
1836-
debug!(path = %path.display(), ?uid, ?gid, "Setting ownership on read_write directory");
1837-
chown(path, uid, gid).into_diagnostic()?;
18381857
}
18391858

18401859
Ok(())
@@ -2163,6 +2182,11 @@ fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String
21632182
#[cfg(test)]
21642183
mod tests {
21652184
use super::*;
2185+
use crate::policy::{FilesystemPolicy, LandlockPolicy, ProcessPolicy};
2186+
#[cfg(unix)]
2187+
use nix::unistd::{Group, User};
2188+
#[cfg(unix)]
2189+
use std::os::unix::fs::{MetadataExt, symlink};
21662190
use temp_env::with_vars;
21672191

21682192
static ENV_LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
@@ -2583,4 +2607,100 @@ filesystem_policy:
25832607
assert_eq!(read.len(), 1);
25842608
assert_eq!(read[0].model, "original-model");
25852609
}
2610+
2611+
#[cfg(unix)]
2612+
fn sandbox_policy_with_read_write(
2613+
path: std::path::PathBuf,
2614+
run_as_user: Option<String>,
2615+
run_as_group: Option<String>,
2616+
) -> SandboxPolicy {
2617+
SandboxPolicy {
2618+
version: 1,
2619+
filesystem: FilesystemPolicy {
2620+
read_only: vec![],
2621+
read_write: vec![path],
2622+
include_workdir: false,
2623+
},
2624+
network: NetworkPolicy::default(),
2625+
landlock: LandlockPolicy::default(),
2626+
process: ProcessPolicy {
2627+
run_as_user,
2628+
run_as_group,
2629+
},
2630+
}
2631+
}
2632+
2633+
#[cfg(unix)]
2634+
#[test]
2635+
fn prepare_read_write_path_creates_missing_directory() {
2636+
let dir = tempfile::tempdir().unwrap();
2637+
let missing = dir.path().join("missing").join("nested");
2638+
2639+
assert!(prepare_read_write_path(&missing).unwrap());
2640+
assert!(missing.is_dir());
2641+
}
2642+
2643+
#[cfg(unix)]
2644+
#[test]
2645+
fn prepare_read_write_path_preserves_existing_directory() {
2646+
let dir = tempfile::tempdir().unwrap();
2647+
let existing = dir.path().join("existing");
2648+
std::fs::create_dir(&existing).unwrap();
2649+
2650+
assert!(!prepare_read_write_path(&existing).unwrap());
2651+
assert!(existing.is_dir());
2652+
}
2653+
2654+
#[cfg(unix)]
2655+
#[test]
2656+
fn prepare_read_write_path_rejects_symlink() {
2657+
let dir = tempfile::tempdir().unwrap();
2658+
let target = dir.path().join("target");
2659+
let link = dir.path().join("link");
2660+
std::fs::create_dir(&target).unwrap();
2661+
symlink(&target, &link).unwrap();
2662+
2663+
let error = prepare_read_write_path(&link).unwrap_err();
2664+
assert!(
2665+
error
2666+
.to_string()
2667+
.contains("is a symlink — refusing to chown"),
2668+
"unexpected error: {error}"
2669+
);
2670+
}
2671+
2672+
#[cfg(unix)]
2673+
#[test]
2674+
fn prepare_filesystem_skips_chown_for_existing_read_write_paths() {
2675+
if nix::unistd::geteuid().is_root() {
2676+
return;
2677+
}
2678+
2679+
let current_user = User::from_uid(nix::unistd::geteuid())
2680+
.unwrap()
2681+
.expect("current user entry");
2682+
let restricted_group = Group::from_gid(nix::unistd::Gid::from_raw(0))
2683+
.unwrap()
2684+
.expect("gid 0 group entry");
2685+
if restricted_group.gid == nix::unistd::getegid() {
2686+
return;
2687+
}
2688+
2689+
let dir = tempfile::tempdir().unwrap();
2690+
let existing = dir.path().join("existing");
2691+
std::fs::create_dir(&existing).unwrap();
2692+
let before = std::fs::metadata(&existing).unwrap();
2693+
2694+
let policy = sandbox_policy_with_read_write(
2695+
existing.clone(),
2696+
Some(current_user.name),
2697+
Some(restricted_group.name),
2698+
);
2699+
2700+
prepare_filesystem(&policy).expect("existing path should not be re-owned");
2701+
2702+
let after = std::fs::metadata(&existing).unwrap();
2703+
assert_eq!(after.uid(), before.uid());
2704+
assert_eq!(after.gid(), before.gid());
2705+
}
25862706
}

0 commit comments

Comments
 (0)