Skip to content

Commit 54cdcfc

Browse files
authored
fix: reject dangling symlinks escaping sandbox in FsSandbox::resolve (#31)
* fix: reject dangling symlinks escaping sandbox in FsSandbox::resolve Signed-off-by: danbugs <danilochiarlone@gmail.com> * fix: follow symlink chains with hop limit, improve test naming and coverage Signed-off-by: danbugs <danilochiarlone@gmail.com> --------- Signed-off-by: danbugs <danilochiarlone@gmail.com>
1 parent f5eb506 commit 54cdcfc

1 file changed

Lines changed: 113 additions & 0 deletions

File tree

host/src/lib.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,41 @@ impl FsSandbox {
767767
let mut out = resolved_ancestor;
768768
for name in tail.into_iter().rev() {
769769
out.push(name);
770+
// Walk the symlink chain (with hop limit) to catch escapes
771+
// through dangling or chained symlinks.
772+
const MAX_SYMLINK_HOPS: usize = 40;
773+
let mut cursor = out.clone();
774+
for _ in 0..MAX_SYMLINK_HOPS {
775+
let Ok(meta) = std::fs::symlink_metadata(&cursor) else {
776+
break;
777+
};
778+
if !meta.file_type().is_symlink() {
779+
break;
780+
}
781+
let target = std::fs::read_link(&cursor)?;
782+
let abs = if target.is_absolute() {
783+
target
784+
} else {
785+
cursor.parent().unwrap_or(&self.root).join(&target)
786+
};
787+
let mut norm = std::path::PathBuf::new();
788+
for c in abs.components() {
789+
match c {
790+
std::path::Component::ParentDir => {
791+
norm.pop();
792+
}
793+
std::path::Component::CurDir => {}
794+
c => norm.push(c),
795+
}
796+
}
797+
if !norm.starts_with(&self.root) {
798+
return Err(anyhow!(
799+
"symlink target escapes mount root: {:?}",
800+
guest_path
801+
));
802+
}
803+
cursor = norm;
804+
}
770805
}
771806
Ok(out)
772807
}
@@ -2488,6 +2523,84 @@ mod tests {
24882523
assert!(err.contains("escapes mount root"), "{err}");
24892524
}
24902525

2526+
#[cfg(unix)]
2527+
#[test]
2528+
fn resolve_rejects_dangling_symlink_escape() {
2529+
use std::os::unix::fs::symlink;
2530+
let root = tmpdir("dangling-escape");
2531+
let outside = tmpdir("dangling-escape-out");
2532+
symlink(outside.join("nonexistent"), root.join("bad_link")).unwrap();
2533+
let fs_sb = FsSandbox::new(&root).unwrap();
2534+
let err = fs_sb.resolve("bad_link").unwrap_err().to_string();
2535+
assert!(
2536+
err.contains("escapes mount root"),
2537+
"expected escape error, got: {err}"
2538+
);
2539+
}
2540+
2541+
#[cfg(unix)]
2542+
#[test]
2543+
fn resolve_allows_valid_internal_symlink() {
2544+
use std::os::unix::fs::symlink;
2545+
let root = tmpdir("valid-internal");
2546+
fs::write(root.join("real_file.txt"), "hello").unwrap();
2547+
symlink(root.join("real_file.txt"), root.join("good_link")).unwrap();
2548+
let fs_sb = FsSandbox::new(&root).unwrap();
2549+
let resolved = fs_sb.resolve("good_link").unwrap();
2550+
assert!(
2551+
resolved.starts_with(&root),
2552+
"expected path under root, got: {resolved:?}"
2553+
);
2554+
}
2555+
2556+
#[cfg(unix)]
2557+
#[test]
2558+
fn resolve_allows_dangling_symlink_inside_root() {
2559+
use std::os::unix::fs::symlink;
2560+
let root = tmpdir("dangling-inside");
2561+
symlink(root.join("future_file.txt"), root.join("ok_link")).unwrap();
2562+
let fs_sb = FsSandbox::new(&root).unwrap();
2563+
let resolved = fs_sb.resolve("ok_link").unwrap();
2564+
assert!(
2565+
resolved.starts_with(&root),
2566+
"expected path under root, got: {resolved:?}"
2567+
);
2568+
}
2569+
2570+
#[cfg(unix)]
2571+
#[test]
2572+
fn resolve_rejects_symlink_chain_escape() {
2573+
use std::os::unix::fs::symlink;
2574+
let root = tmpdir("chain-escape");
2575+
let outside = tmpdir("chain-outside");
2576+
symlink(&outside, root.join("link_b")).unwrap();
2577+
symlink(root.join("link_b"), root.join("link_a")).unwrap();
2578+
let fs_sb = FsSandbox::new(&root).unwrap();
2579+
let err = fs_sb.resolve("link_a").unwrap_err().to_string();
2580+
assert!(
2581+
err.contains("escapes mount root"),
2582+
"expected escape error, got: {err}"
2583+
);
2584+
}
2585+
2586+
#[cfg(unix)]
2587+
#[test]
2588+
fn resolve_rejects_chained_dangling_symlink_escape() {
2589+
use std::os::unix::fs::symlink;
2590+
let root = tmpdir("chain-dangling");
2591+
let outside = tmpdir("chain-dangling-out");
2592+
// link_b -> dangling path outside root
2593+
symlink(outside.join("nonexistent"), root.join("link_b")).unwrap();
2594+
// link_a -> link_b (which is under root, but chains outside)
2595+
symlink(root.join("link_b"), root.join("link_a")).unwrap();
2596+
let fs_sb = FsSandbox::new(&root).unwrap();
2597+
let err = fs_sb.resolve("link_a").unwrap_err().to_string();
2598+
assert!(
2599+
err.contains("escapes mount root"),
2600+
"expected escape error, got: {err}"
2601+
);
2602+
}
2603+
24912604
#[test]
24922605
fn resolve_allows_paths_under_the_root() {
24932606
let root = tmpdir("allow");

0 commit comments

Comments
 (0)