Skip to content
Merged
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
113 changes: 113 additions & 0 deletions host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,41 @@ impl FsSandbox {
let mut out = resolved_ancestor;
for name in tail.into_iter().rev() {
out.push(name);
// Walk the symlink chain (with hop limit) to catch escapes
// through dangling or chained symlinks.
const MAX_SYMLINK_HOPS: usize = 40;
let mut cursor = out.clone();
for _ in 0..MAX_SYMLINK_HOPS {
let Ok(meta) = std::fs::symlink_metadata(&cursor) else {
break;
};
if !meta.file_type().is_symlink() {
break;
}
let target = std::fs::read_link(&cursor)?;
let abs = if target.is_absolute() {
target
} else {
cursor.parent().unwrap_or(&self.root).join(&target)
};
let mut norm = std::path::PathBuf::new();
for c in abs.components() {
match c {
std::path::Component::ParentDir => {
norm.pop();
}
std::path::Component::CurDir => {}
c => norm.push(c),
}
}
if !norm.starts_with(&self.root) {
return Err(anyhow!(
"symlink target escapes mount root: {:?}",
guest_path
));
}
cursor = norm;
}
}
Ok(out)
}
Expand Down Expand Up @@ -2488,6 +2523,84 @@ mod tests {
assert!(err.contains("escapes mount root"), "{err}");
}

#[cfg(unix)]
#[test]
fn resolve_rejects_dangling_symlink_escape() {
use std::os::unix::fs::symlink;
let root = tmpdir("dangling-escape");
let outside = tmpdir("dangling-escape-out");
symlink(outside.join("nonexistent"), root.join("bad_link")).unwrap();
let fs_sb = FsSandbox::new(&root).unwrap();
let err = fs_sb.resolve("bad_link").unwrap_err().to_string();
assert!(
err.contains("escapes mount root"),
"expected escape error, got: {err}"
);
}

#[cfg(unix)]
#[test]
fn resolve_allows_valid_internal_symlink() {
use std::os::unix::fs::symlink;
let root = tmpdir("valid-internal");
fs::write(root.join("real_file.txt"), "hello").unwrap();
symlink(root.join("real_file.txt"), root.join("good_link")).unwrap();
let fs_sb = FsSandbox::new(&root).unwrap();
let resolved = fs_sb.resolve("good_link").unwrap();
assert!(
resolved.starts_with(&root),
"expected path under root, got: {resolved:?}"
);
}

#[cfg(unix)]
#[test]
fn resolve_allows_dangling_symlink_inside_root() {
use std::os::unix::fs::symlink;
let root = tmpdir("dangling-inside");
symlink(root.join("future_file.txt"), root.join("ok_link")).unwrap();
let fs_sb = FsSandbox::new(&root).unwrap();
let resolved = fs_sb.resolve("ok_link").unwrap();
assert!(
resolved.starts_with(&root),
"expected path under root, got: {resolved:?}"
);
}

#[cfg(unix)]
#[test]
fn resolve_rejects_symlink_chain_escape() {
use std::os::unix::fs::symlink;
let root = tmpdir("chain-escape");
let outside = tmpdir("chain-outside");
symlink(&outside, root.join("link_b")).unwrap();
symlink(root.join("link_b"), root.join("link_a")).unwrap();
let fs_sb = FsSandbox::new(&root).unwrap();
let err = fs_sb.resolve("link_a").unwrap_err().to_string();
assert!(
err.contains("escapes mount root"),
"expected escape error, got: {err}"
);
}

#[cfg(unix)]
#[test]
fn resolve_rejects_chained_dangling_symlink_escape() {
use std::os::unix::fs::symlink;
let root = tmpdir("chain-dangling");
let outside = tmpdir("chain-dangling-out");
// link_b -> dangling path outside root
symlink(outside.join("nonexistent"), root.join("link_b")).unwrap();
// link_a -> link_b (which is under root, but chains outside)
symlink(root.join("link_b"), root.join("link_a")).unwrap();
let fs_sb = FsSandbox::new(&root).unwrap();
let err = fs_sb.resolve("link_a").unwrap_err().to_string();
assert!(
err.contains("escapes mount root"),
"expected escape error, got: {err}"
);
}

#[test]
fn resolve_allows_paths_under_the_root() {
let root = tmpdir("allow");
Expand Down
Loading