Skip to content

fix: reject dangling symlinks escaping sandbox in FsSandbox::resolve - #31

Merged
danbugs merged 2 commits into
mainfrom
fix/symlink-escape
May 15, 2026
Merged

fix: reject dangling symlinks escaping sandbox in FsSandbox::resolve#31
danbugs merged 2 commits into
mainfrom
fix/symlink-escape

Conversation

@danbugs

@danbugs danbugs commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

FsSandbox::resolve() failed to detect symlinks whose target does not exist
(dangling symlinks). The ancestor-walk loop uses Path::exists(), which follows
symlinks — for a dangling symlink, exists() returns false, so the symlink name
was appended to the output path without ever being canonicalized or checked. A
subsequent fs_write through that symlink would create files outside the sandbox
root.

  • After reconstructing the path from tail components, each component is now
    checked with symlink_metadata() (which does not follow symlinks)
  • If a component is a symlink, its target is resolved and verified to be under
    the sandbox root
  • 4 regression tests added: dangling escape, valid internal, dangling-inside-root
    (allowed), and symlink chain escape

Test plan

  • test_resolve_dangling_symlink_escape — dangling symlink to outside root → Err
  • test_resolve_valid_internal_symlink — symlink to file inside root → Ok
  • test_resolve_dangling_symlink_inside_root — dangling symlink inside root → Ok
  • test_resolve_symlink_chain_escape — chained symlinks to outside → Err
  • All existing resolve tests still pass

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens FsSandbox::resolve() against sandbox escapes by ensuring symlinks in the “non-existing tail” of a path are detected (including dangling symlinks) and rejected when their targets escape the sandbox root.

Changes:

  • Add per-component symlink_metadata() checks while reconstructing the non-existing tail, rejecting symlinks whose (lexically normalized) targets are outside self.root.
  • Add Unix-only regression tests for dangling symlink escape attempts, valid internal symlinks, dangling-but-in-root symlinks, and a symlink chain case.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread host/src/lib.rs Outdated
Comment on lines +765 to +792
// Catch symlinks in the non-existing tail — including dangling ones.
if let Ok(meta) = std::fs::symlink_metadata(&out) {
if meta.file_type().is_symlink() {
let target = std::fs::read_link(&out)?;
let abs = if target.is_absolute() {
target
} else {
out.parent().unwrap_or(&self.root).join(&target)
};
// Normalise the absolute target (resolve .. etc) without
// touching the filesystem, then check the prefix.
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
));
}
}
Comment thread host/src/lib.rs Outdated
Comment on lines +2520 to +2521
// Create a dangling symlink whose target is outside root.
symlink("/tmp/nonexistent-outside-root", root.join("bad_link")).unwrap();
Comment thread host/src/lib.rs Outdated
Comment on lines +2517 to +2533
fn test_resolve_dangling_symlink_escape() {
use std::os::unix::fs::symlink;
let root = tmpdir("dangling-escape");
// Create a dangling symlink whose target is outside root.
symlink("/tmp/nonexistent-outside-root", 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 test_resolve_valid_internal_symlink() {
use std::os::unix::fs::symlink;
Comment thread host/src/lib.rs Outdated
Comment on lines +2565 to +2576
fn test_resolve_symlink_chain_escape() {
use std::os::unix::fs::symlink;
let root = tmpdir("chain-escape");
let outside = tmpdir("chain-outside");
// symlink B -> outside (dangling or not, target is outside root)
symlink(&outside, root.join("link_b")).unwrap();
// symlink A -> link_b
symlink(root.join("link_b"), root.join("link_a")).unwrap();
let fs_sb = FsSandbox::new(&root).unwrap();
// Resolving link_a should fail because it chains through link_b
// which points outside root.
let err = fs_sb.resolve("link_a").unwrap_err().to_string();

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linux Benchmarks

Details
Benchmark suite Current: 095a075 Previous: f5eb506 Ratio
hello_world (median) 20 ms 20 ms 1
pandas (median) 110 ms 110 ms 1
density (per VM) 7 MB 7 MB 1
snapshot (disk) 385 MiB 385 MiB 1

This comment was automatically generated by workflow using github-action-benchmark.

danbugs added 2 commits May 15, 2026 06:06
Signed-off-by: danbugs <danilochiarlone@gmail.com>
…verage

Signed-off-by: danbugs <danilochiarlone@gmail.com>
@danbugs
danbugs force-pushed the fix/symlink-escape branch from 1397275 to 095a075 Compare May 15, 2026 06:07

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows Benchmarks

Details
Benchmark suite Current: 095a075 Previous: f5eb506 Ratio
hello_world (median) 291 ms 221 ms 1.32
pandas (median) 917 ms 697 ms 1.32
density (per VM) 6 MB 6 MB 1
snapshot (disk) 393 MiB 393 MiB 1

This comment was automatically generated by workflow using github-action-benchmark.

@danbugs
danbugs merged commit 54cdcfc into main May 15, 2026
79 checks passed
@danbugs
danbugs deleted the fix/symlink-escape branch May 15, 2026 06:23
@danbugs danbugs mentioned this pull request May 15, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants