Skip to content

Commit 4f06385

Browse files
authored
fix(fs): prevent sandbox escape via TOCTOU fallback in RealFs::resolve (#1040)
Closes #980
1 parent 0e6f906 commit 4f06385

File tree

1 file changed

+154
-3
lines changed

1 file changed

+154
-3
lines changed

crates/bashkit/src/fs/realfs.rs

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,28 @@ impl RealFs {
171171
}
172172
}
173173

174-
// Fallback: just use the joined path (will fail at the OS level
175-
// if parent doesn't exist, which is the correct POSIX behavior)
176-
Ok(joined)
174+
// SECURITY: Never return a raw path without traversal validation.
175+
// The parent doesn't exist and can't be canonicalized, so we cannot
176+
// verify containment with certainty. Returning the raw `joined` path
177+
// here would skip all symlink/traversal checks, creating a TOCTOU
178+
// window where an attacker could race to create a symlink between
179+
// the exists() check above and actual file I/O. (issue #980)
180+
//
181+
// Defense-in-depth: normalize the host path logically and verify it
182+
// stays under root. This catches any `..` that survived vpath
183+
// normalization as well as any future changes to the normalization
184+
// logic.
185+
let normalized = normalize_host_path(&joined);
186+
if !normalized.starts_with(&self.root) {
187+
return Err(IoError::new(
188+
ErrorKind::PermissionDenied,
189+
"path escapes realfs root",
190+
));
191+
}
192+
// Even with logical normalization, the path hasn't been verified on
193+
// disk (no canonicalize). This is acceptable only because the parent
194+
// truly doesn't exist — there's nothing on disk to symlink through.
195+
Ok(normalized)
177196
}
178197

179198
/// Check that the mode allows writes. Returns PermissionDenied if readonly.
@@ -240,6 +259,32 @@ fn metadata_from_std(m: &std::fs::Metadata) -> Metadata {
240259
}
241260
}
242261

262+
/// Normalize a host path by logically resolving `.` and `..` components.
263+
///
264+
/// Unlike `std::fs::canonicalize`, this does not touch the filesystem, so it
265+
/// works for paths whose parents don't exist yet. Used in the `resolve()`
266+
/// fallback to validate containment without a TOCTOU window.
267+
fn normalize_host_path(path: &Path) -> PathBuf {
268+
let mut components = Vec::new();
269+
for component in path.components() {
270+
match component {
271+
std::path::Component::ParentDir => {
272+
// Only pop Normal components; never pop RootDir or Prefix
273+
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
274+
components.pop();
275+
}
276+
}
277+
std::path::Component::CurDir => {}
278+
c => components.push(c),
279+
}
280+
}
281+
if components.is_empty() {
282+
PathBuf::from("/")
283+
} else {
284+
components.iter().collect()
285+
}
286+
}
287+
243288
/// Normalize a virtual path: collapse `.` and `..`, ensure absolute.
244289
fn normalize_vpath(path: &Path) -> PathBuf {
245290
let mut components = Vec::new();
@@ -610,6 +655,112 @@ mod tests {
610655
assert!(result.is_err());
611656
}
612657

658+
// --- Security tests for issue #980: TOCTOU fallback sandbox escape ---
659+
660+
#[test]
661+
fn normalize_host_path_resolves_dotdot() {
662+
let p = normalize_host_path(Path::new("/a/b/../c"));
663+
assert_eq!(p, PathBuf::from("/a/c"));
664+
665+
let p = normalize_host_path(Path::new("/a/b/../../c"));
666+
assert_eq!(p, PathBuf::from("/c"));
667+
668+
// Can't go above root
669+
let p = normalize_host_path(Path::new("/a/../../../x"));
670+
assert_eq!(p, PathBuf::from("/x"));
671+
}
672+
673+
#[test]
674+
fn normalize_host_path_preserves_absolute() {
675+
let p = normalize_host_path(Path::new("/tmp/sandbox/./foo/../bar"));
676+
assert_eq!(p, PathBuf::from("/tmp/sandbox/bar"));
677+
}
678+
679+
#[test]
680+
fn resolve_fallback_validates_containment() {
681+
// When the parent doesn't exist, resolve must still validate
682+
// that the path stays under root (defense-in-depth).
683+
let dir = setup();
684+
let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
685+
686+
// Valid non-existent path under root — should succeed
687+
let result = fs.resolve(Path::new("/newdir/newfile.txt"));
688+
assert!(
689+
result.is_ok(),
690+
"valid non-existent path under root should succeed"
691+
);
692+
let resolved = result.unwrap();
693+
assert!(
694+
resolved.starts_with(dir.path()),
695+
"resolved path must be under root"
696+
);
697+
}
698+
699+
#[test]
700+
fn resolve_fallback_returns_normalized_path() {
701+
// The fallback must return a normalized path, not the raw joined path.
702+
// This ensures no stale `..` or `.` components leak through.
703+
let dir = setup();
704+
let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
705+
706+
let result = fs.resolve(Path::new("/a/b/../c/file.txt"));
707+
assert!(result.is_ok());
708+
let resolved = result.unwrap();
709+
// The resolved path should not contain ".."
710+
assert!(
711+
!resolved.to_string_lossy().contains(".."),
712+
"fallback path must be normalized, got: {}",
713+
resolved.display()
714+
);
715+
assert!(resolved.starts_with(dir.path()));
716+
}
717+
718+
#[tokio::test]
719+
async fn security_traversal_blocked_all_paths() {
720+
// Comprehensive traversal test: all traversal attempts must fail,
721+
// regardless of which code path in resolve() handles them.
722+
let dir = setup();
723+
let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
724+
725+
let traversal_paths = [
726+
"/../../../etc/passwd",
727+
"/../../etc/shadow",
728+
"/subdir/../../etc/passwd",
729+
"/./../../etc/passwd",
730+
];
731+
for vpath in &traversal_paths {
732+
let result = fs.read(Path::new(vpath)).await;
733+
// normalize_vpath collapses these to root-relative, so they
734+
// resolve under root. What matters: no actual /etc/passwd content.
735+
if let Ok(data) = &result {
736+
let data_str = String::from_utf8_lossy(data);
737+
assert!(
738+
!data_str.contains("root:"),
739+
"traversal leaked /etc/passwd via path {vpath}"
740+
);
741+
}
742+
}
743+
}
744+
745+
#[tokio::test]
746+
async fn security_nonexistent_nested_stays_under_root() {
747+
// Write to deeply nested non-existent path should create under root,
748+
// not escape via fallback.
749+
let dir = setup();
750+
let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
751+
752+
// This goes through the fallback (parent doesn't exist).
753+
// The resolved path must be under root.
754+
let result = fs
755+
.write(Path::new("/deep/nested/dir/file.txt"), b"safe")
756+
.await;
757+
// Should succeed (write creates parent dirs) and file must be under root
758+
if result.is_ok() {
759+
let expected = dir.path().join("deep/nested/dir/file.txt");
760+
assert!(expected.exists(), "file must be created under root");
761+
}
762+
}
763+
613764
#[test]
614765
fn debug_display() {
615766
let dir = setup();

0 commit comments

Comments
 (0)