Skip to content

Commit 6dadfae

Browse files
fix(sandbox): skip read-only mounts during recursive chown of /sandbox
The sandbox supervisor crashed with EROFS when the recursive chown of /sandbox encountered read-only submounts. This is common in gVisor-based Kubernetes deployments where read-only volume mounts are the only way to enforce per-directory immutability (Landlock is unavailable under gVisor). The fix adds two guards to the ownership walk: 1. Mount-boundary detection via st_dev comparison — paths on a different filesystem than /sandbox are skipped entirely, avoiding the chown call on nested read-only mounts. 2. EROFS tolerance — if chown still returns EROFS (e.g. the root mount itself is read-only), the error is logged at debug level and startup continues. Symlink skipping (already present) is preserved and extracted into the recursive walker for consistency. Manually verified on a kind cluster with a read-only PVC mounted at /sandbox/readonly-data: the pod starts successfully, writable paths are owned by the sandbox user, and the read-only mount retains root ownership. Kubernetes e2e test coverage is a separate follow-up. Closes #2294 Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
1 parent d0961cd commit 6dadfae

1 file changed

Lines changed: 110 additions & 7 deletions

File tree

crates/openshell-supervisor-process/src/process.rs

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,9 +1168,14 @@ fn rewrite_group_at(path: &Path, gid: &str) -> Result<()> {
11681168
/// Symlinks are skipped (not followed) to prevent privilege escalation via
11691169
/// malicious container images. The TOCTOU window is not exploitable because
11701170
/// no untrusted process is running yet.
1171+
///
1172+
/// The walk does not cross filesystem boundaries (compares `st_dev`) so that
1173+
/// read-only mounts nested under `/sandbox` are left untouched. Any remaining
1174+
/// `EROFS` errors from `chown` are logged as warnings rather than aborting
1175+
/// startup.
11711176
#[cfg(unix)]
11721177
fn chown_sandbox_home(root: &Path, uid: Option<Uid>, gid: Option<Gid>) -> Result<()> {
1173-
use nix::unistd::chown;
1178+
use std::os::unix::fs::MetadataExt;
11741179

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

1183-
chown(root, uid, gid).into_diagnostic()?;
1188+
let root_dev = meta.dev();
1189+
chown_recursive(root, uid, gid, root_dev)
1190+
}
1191+
1192+
#[cfg(unix)]
1193+
fn chown_recursive(path: &Path, uid: Option<Uid>, gid: Option<Gid>, root_dev: u64) -> Result<()> {
1194+
use nix::unistd::chown;
1195+
use std::os::unix::fs::MetadataExt;
1196+
1197+
let meta = std::fs::symlink_metadata(path).into_diagnostic()?;
1198+
1199+
if meta.dev() != root_dev {
1200+
debug!(path = %path.display(), "Skipping mount boundary during sandbox home chown");
1201+
return Ok(());
1202+
}
1203+
1204+
if let Err(e) = chown(path, uid, gid) {
1205+
if e == nix::errno::Errno::EROFS {
1206+
tracing::warn!(path = %path.display(), "Skipping read-only path during sandbox home chown");
1207+
return Ok(());
1208+
}
1209+
return Err(e).into_diagnostic();
1210+
}
11841211

11851212
if meta.is_dir()
1186-
&& let Ok(entries) = std::fs::read_dir(root)
1213+
&& let Ok(entries) = std::fs::read_dir(path)
11871214
{
11881215
for entry in entries {
11891216
let entry = entry.into_diagnostic()?;
1190-
let path = entry.path();
1191-
if path
1217+
let child = entry.path();
1218+
if child
11921219
.symlink_metadata()
11931220
.is_ok_and(|m| m.file_type().is_symlink())
11941221
{
1195-
debug!(path = %path.display(), "Skipping symlink during sandbox home chown");
1222+
debug!(path = %child.display(), "Skipping symlink during sandbox home chown");
11961223
continue;
11971224
}
1198-
chown_sandbox_home(&path, uid, gid)?;
1225+
chown_recursive(&child, uid, gid, root_dev)?;
11991226
}
12001227
}
12011228

@@ -2115,6 +2142,82 @@ mod tests {
21152142
.expect("should skip symlink children without error");
21162143
}
21172144

2145+
#[cfg(unix)]
2146+
#[test]
2147+
fn chown_recursive_skips_different_device() {
2148+
use std::os::unix::fs::MetadataExt;
2149+
2150+
let dir = tempfile::tempdir().unwrap();
2151+
let root = dir.path().join("sandbox");
2152+
std::fs::create_dir(&root).unwrap();
2153+
let child = root.join("file.txt");
2154+
std::fs::write(&child, "hello").unwrap();
2155+
2156+
let before_root = std::fs::metadata(&root).unwrap();
2157+
let before_child = std::fs::metadata(&child).unwrap();
2158+
2159+
let uid = Some(nix::unistd::geteuid());
2160+
let gid = Some(nix::unistd::getegid());
2161+
2162+
// Pass a fake root_dev that doesn't match the temp directory's device.
2163+
// chown_recursive should skip the path entirely instead of failing.
2164+
let fake_dev = std::fs::symlink_metadata(&root)
2165+
.unwrap()
2166+
.dev()
2167+
.wrapping_add(1);
2168+
chown_recursive(&root, uid, gid, fake_dev)
2169+
.expect("should skip paths on a different device");
2170+
2171+
let after_root = std::fs::metadata(&root).unwrap();
2172+
let after_child = std::fs::metadata(&child).unwrap();
2173+
assert_eq!(
2174+
before_root.uid(),
2175+
after_root.uid(),
2176+
"root directory should not have been chowned"
2177+
);
2178+
assert_eq!(
2179+
before_child.uid(),
2180+
after_child.uid(),
2181+
"child file should not have been chowned"
2182+
);
2183+
}
2184+
2185+
#[cfg(unix)]
2186+
#[test]
2187+
fn chown_sandbox_home_does_not_chown_across_device_boundary() {
2188+
use std::os::unix::fs::MetadataExt;
2189+
2190+
let dir = tempfile::tempdir().unwrap();
2191+
let root = dir.path().join("sandbox");
2192+
std::fs::create_dir(&root).unwrap();
2193+
std::fs::write(root.join("owned.txt"), "writable").unwrap();
2194+
let subdir = root.join("mount");
2195+
std::fs::create_dir(&subdir).unwrap();
2196+
std::fs::write(subdir.join("skipped.txt"), "read-only").unwrap();
2197+
2198+
let expected_uid = nix::unistd::geteuid();
2199+
let expected_gid = nix::unistd::getegid();
2200+
2201+
chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap();
2202+
2203+
let owned = std::fs::metadata(root.join("owned.txt")).unwrap();
2204+
assert_eq!(
2205+
owned.uid(),
2206+
expected_uid.as_raw(),
2207+
"same-device file should be chowned"
2208+
);
2209+
2210+
// subdir is on the same device in this test, so it WILL be chowned.
2211+
// This test documents the normal same-device behavior. The cross-device
2212+
// skip is tested by chown_recursive_skips_different_device above.
2213+
let sub = std::fs::metadata(&subdir).unwrap();
2214+
assert_eq!(
2215+
sub.uid(),
2216+
expected_uid.as_raw(),
2217+
"same-device subdirectory should be chowned"
2218+
);
2219+
}
2220+
21182221
#[cfg(unix)]
21192222
#[test]
21202223
fn rewrite_passwd_modifies_existing_sandbox_entry() {

0 commit comments

Comments
 (0)