diff --git a/host/src/lib.rs b/host/src/lib.rs index 2761587..da95453 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -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) } @@ -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");