Skip to content
107 changes: 94 additions & 13 deletions crates/openshell-supervisor-process/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1168,10 +1168,12 @@ fn rewrite_group_at(path: &Path, gid: &str) -> Result<()> {
/// Symlinks are skipped (not followed) to prevent privilege escalation via
/// malicious container images. The TOCTOU window is not exploitable because
/// no untrusted process is running yet.
///
/// `EROFS` errors from `chown` are logged at debug level and the walk still
/// recurses into children (a read-only directory may contain writable submounts
/// or siblings that must be owned by the sandbox user).
#[cfg(unix)]
fn chown_sandbox_home(root: &Path, uid: Option<Uid>, gid: Option<Gid>) -> Result<()> {
use nix::unistd::chown;

let meta = std::fs::symlink_metadata(root).into_diagnostic()?;
if meta.file_type().is_symlink() {
return Err(miette::miette!(
Expand All @@ -1180,22 +1182,38 @@ fn chown_sandbox_home(root: &Path, uid: Option<Uid>, gid: Option<Gid>) -> Result
));
}

chown(root, uid, gid).into_diagnostic()?;
chown_recursive(root, uid, gid, &nix::unistd::chown)
}

#[cfg(unix)]
fn chown_recursive(
path: &Path,
uid: Option<Uid>,
gid: Option<Gid>,
do_chown: &impl Fn(&Path, Option<Uid>, Option<Gid>) -> nix::Result<()>,
) -> Result<()> {
let meta = std::fs::symlink_metadata(path).into_diagnostic()?;

Comment on lines +1195 to +1196

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we move the symlink check that is currently performed on child in the loop below to here?

@varshaprasad96 varshaprasad96 Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 4cd9059. Moved the symlink check into the top of chown_recursive (reusing the symlink_metadata call already there), which also eliminated the redundant per-child symlink_metadata in the loop

if meta.file_type().is_symlink() {
debug!(path = %path.display(), "Skipping symlink during sandbox home chown");
return Ok(());
}

if let Err(e) = do_chown(path, uid, gid) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: CWE-367/CWE-59: symlink_metadata does not make this following chown race-safe. A PVC mounted read-only here can still be changed by another pod; after EROFS, the new descent lets an attacker swap a child or ancestor for a symlink and make the root supervisor chown an arbitrary file in its namespace. Minimally stop descending that directory after EROFS while continuing its siblings. If nested writable submounts must remain supported, use an fd-relative walker with no-follow opens and fchown, then add a deterministic symlink-swap regression test.

if e == nix::errno::Errno::EROFS {
Comment thread
varshaprasad96 marked this conversation as resolved.
debug!(path = %path.display(), "Skipping read-only path during sandbox home chown");
} else {
return Err(e).into_diagnostic();
}
}

if meta.is_dir()
&& let Ok(entries) = std::fs::read_dir(root)
&& let Ok(entries) = std::fs::read_dir(path)
{
for entry in entries {
let entry = entry.into_diagnostic()?;
let path = entry.path();
if path
.symlink_metadata()
.is_ok_and(|m| m.file_type().is_symlink())
{
debug!(path = %path.display(), "Skipping symlink during sandbox home chown");
continue;
}
chown_sandbox_home(&path, uid, gid)?;
let child = entry.path();
chown_recursive(&child, uid, gid, do_chown)?;
}
}

Expand Down Expand Up @@ -2115,6 +2133,69 @@ mod tests {
.expect("should skip symlink children without error");
}

#[cfg(unix)]
#[test]
fn chown_recursive_skips_erofs_and_continues() {
use std::sync::{Arc, Mutex};

let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("sandbox");
std::fs::create_dir(&root).unwrap();

let readonly_dir = root.join("ro-mount");
std::fs::create_dir(&readonly_dir).unwrap();
std::fs::write(readonly_dir.join("writable-child.txt"), "data").unwrap();

std::fs::write(root.join("writable-sibling.txt"), "data").unwrap();

let uid = Some(nix::unistd::geteuid());
let gid = Some(nix::unistd::getegid());

let chowned: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
let chowned_ref = Arc::clone(&chowned);

let readonly_dir_clone = readonly_dir.clone();
let fake_chown =
move |path: &Path, _uid: Option<Uid>, _gid: Option<Gid>| -> nix::Result<()> {
if path == readonly_dir_clone {
return Err(nix::errno::Errno::EROFS);
}
chowned_ref.lock().unwrap().push(path.to_path_buf());
Ok(())
};

chown_recursive(&root, uid, gid, &fake_chown).expect("EROFS should be handled gracefully");

let chowned = chowned.lock().unwrap();
assert!(chowned.contains(&root), "root should have been chowned");
assert!(
chowned.contains(&readonly_dir.join("writable-child.txt")),
"writable child under EROFS directory should still be chowned"
);
assert!(
chowned.contains(&root.join("writable-sibling.txt")),
"writable sibling should still be chowned"
);
}

#[cfg(unix)]
#[test]
fn chown_recursive_propagates_non_erofs_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("sandbox");
std::fs::create_dir(&root).unwrap();

let uid = Some(nix::unistd::geteuid());
let gid = Some(nix::unistd::getegid());

let fake_chown = |_path: &Path, _uid: Option<Uid>, _gid: Option<Gid>| -> nix::Result<()> {
Err(nix::errno::Errno::EPERM)
};

let result = chown_recursive(&root, uid, gid, &fake_chown);
assert!(result.is_err(), "non-EROFS errors should propagate");
}

#[cfg(unix)]
#[test]
fn rewrite_passwd_modifies_existing_sandbox_entry() {
Expand Down
Loading