@@ -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.
244289fn 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