Skip to content
Open
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
125 changes: 113 additions & 12 deletions crates/openshell-supervisor-process/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1168,9 +1168,14 @@ 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.
///
/// The walk does not cross filesystem boundaries (compares `st_dev`) so that
/// read-only mounts nested under `/sandbox` are left untouched. Any remaining
/// `EROFS` errors from `chown` are logged at debug level and the walk still
/// recurses into children (a read-only directory may contain writable submounts).
#[cfg(unix)]
fn chown_sandbox_home(root: &Path, uid: Option<Uid>, gid: Option<Gid>) -> Result<()> {
use nix::unistd::chown;
use std::os::unix::fs::MetadataExt;

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

chown(root, uid, gid).into_diagnostic()?;
let root_dev = meta.dev();
chown_recursive(root, uid, gid, root_dev)
}

#[cfg(unix)]
fn chown_recursive(path: &Path, uid: Option<Uid>, gid: Option<Gid>, root_dev: u64) -> Result<()> {
use nix::unistd::chown;
use std::os::unix::fs::MetadataExt;

let meta = std::fs::symlink_metadata(path).into_diagnostic()?;

Comment on lines +1197 to +1198

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 meta.dev() != root_dev {
debug!(path = %path.display(), "Skipping mount boundary during sandbox home chown");
return Ok(());
}

if let Err(e) = chown(path, uid, gid) {
if e == nix::errno::Errno::EROFS {
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, root_dev)?;
}
}

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

#[cfg(unix)]
#[test]
fn chown_recursive_skips_different_device() {
use std::os::unix::fs::MetadataExt;

let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("sandbox");
std::fs::create_dir(&root).unwrap();
let child = root.join("file.txt");
std::fs::write(&child, "hello").unwrap();

let before_root = std::fs::metadata(&root).unwrap();
let before_child = std::fs::metadata(&child).unwrap();

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

// Pass a fake root_dev that doesn't match the temp directory's device.
// chown_recursive should skip the path entirely instead of failing.
let fake_dev = std::fs::symlink_metadata(&root)
.unwrap()
.dev()
.wrapping_add(1);
chown_recursive(&root, uid, gid, fake_dev)
.expect("should skip paths on a different device");

let after_root = std::fs::metadata(&root).unwrap();
let after_child = std::fs::metadata(&child).unwrap();
assert_eq!(
before_root.uid(),
after_root.uid(),
"root directory should not have been chowned"
);
assert_eq!(
before_child.uid(),
after_child.uid(),
"child file should not have been chowned"
);
}

#[cfg(unix)]
#[test]
fn chown_sandbox_home_does_not_chown_across_device_boundary() {
use std::os::unix::fs::MetadataExt;

let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("sandbox");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("owned.txt"), "writable").unwrap();
let subdir = root.join("mount");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("skipped.txt"), "read-only").unwrap();

let expected_uid = nix::unistd::geteuid();
let expected_gid = nix::unistd::getegid();

chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap();

let owned = std::fs::metadata(root.join("owned.txt")).unwrap();
assert_eq!(
owned.uid(),
expected_uid.as_raw(),
"same-device file should be chowned"
);

// subdir is on the same device in this test, so it WILL be chowned.
// This test documents the normal same-device behavior. The cross-device
// skip is tested by chown_recursive_skips_different_device above.
let sub = std::fs::metadata(&subdir).unwrap();
assert_eq!(
sub.uid(),
expected_uid.as_raw(),
"same-device subdirectory should be chowned"
);
}

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