@@ -10,6 +10,120 @@ use crate::handler::Handler;
1010use crate :: mime;
1111use crate :: proto:: { Body , Method , Request , Response , StatusCode } ;
1212
13+ /// Race-free confined file opening beneath the static-file root.
14+ ///
15+ /// Uses `openat2(2)` with `RESOLVE_BENEATH` so path resolution is confined
16+ /// strictly beneath the root dirfd: `..` escaping the root, absolute symlinks,
17+ /// and symlinks whose target leaves the root all fail atomically, while in-root
18+ /// symlinks still resolve. Making open+confinement a single syscall closes the
19+ /// TOCTOU window between the old canonicalize check and `File::open`.
20+ ///
21+ /// This module is the other scoped `unsafe` user besides `privdrop`; it opts
22+ /// back in under the crate's `deny(unsafe_code)` with a local `allow`.
23+ #[ cfg( all( feature = "hardened-fs" , target_os = "linux" ) ) ]
24+ #[ allow( unsafe_code) ]
25+ mod confined {
26+ use std:: ffi:: CString ;
27+ use std:: io;
28+ use std:: mem:: size_of;
29+ use std:: os:: unix:: ffi:: OsStrExt ;
30+ use std:: os:: unix:: io:: { AsRawFd , FromRawFd } ;
31+ use std:: path:: Path ;
32+ use std:: sync:: atomic:: { AtomicU8 , Ordering } ;
33+
34+ /// `RESOLVE_BENEATH`: reject any resolution step that would escape the
35+ /// dirfd (`..` above it, absolute paths, out-of-tree symlink targets).
36+ const RESOLVE_BENEATH : u64 = 0x08 ;
37+ /// `openat2(2)` syscall number — identical (437) across all Linux
38+ /// architectures, hardcoded because a matching libc const may be absent.
39+ const SYS_OPENAT2 : libc:: c_long = 437 ;
40+
41+ /// Whether `openat2` is available on the running kernel: 0 = unknown,
42+ /// 1 = yes, 2 = no. Caches an `ENOSYS` result so an old kernel is probed
43+ /// once, not per request.
44+ static SUPPORTED : AtomicU8 = AtomicU8 :: new ( 0 ) ;
45+
46+ /// Mirror of the kernel's `struct open_how` (see `openat2(2)`).
47+ #[ repr( C ) ]
48+ struct OpenHow {
49+ flags : u64 ,
50+ mode : u64 ,
51+ resolve : u64 ,
52+ }
53+
54+ fn path_to_cstring ( path : & Path ) -> io:: Result < CString > {
55+ CString :: new ( path. as_os_str ( ) . as_bytes ( ) )
56+ . map_err ( |_| io:: Error :: new ( io:: ErrorKind :: InvalidInput , "path contains NUL" ) )
57+ }
58+
59+ /// Open `rel` (a relative path) for reading, confined strictly beneath
60+ /// `root`.
61+ ///
62+ /// Returns `Ok(None)` when `openat2` is unavailable (old kernel: `ENOSYS`),
63+ /// signalling the caller to use the canonicalize-based fallback. Any other
64+ /// failure — including confinement rejections (`EXDEV`, `ELOOP`, `ENOTDIR`)
65+ /// and `ENOENT` — is returned as `Err` for the caller to map to 404.
66+ pub fn open_beneath ( root : & Path , rel : & Path ) -> io:: Result < Option < std:: fs:: File > > {
67+ // Old kernel already detected: skip the syscall entirely.
68+ if SUPPORTED . load ( Ordering :: Relaxed ) == 2 {
69+ return Ok ( None ) ;
70+ }
71+
72+ let root_c = path_to_cstring ( root) ?;
73+ let rel_c = path_to_cstring ( rel) ?;
74+
75+ // Open the root as an `O_PATH` directory fd. SAFETY: `root_c` is a valid
76+ // NUL-terminated C string; the flags are valid; the return value is
77+ // checked below.
78+ let raw_dir = unsafe {
79+ libc:: open (
80+ root_c. as_ptr ( ) ,
81+ libc:: O_PATH | libc:: O_DIRECTORY | libc:: O_CLOEXEC ,
82+ )
83+ } ;
84+ if raw_dir < 0 {
85+ return Err ( io:: Error :: last_os_error ( ) ) ;
86+ }
87+ // Wrap immediately so the dirfd is closed (RAII) on every return path.
88+ // SAFETY: `raw_dir` is a fresh, owned, valid fd we just obtained.
89+ let dirfd = unsafe { std:: fs:: File :: from_raw_fd ( raw_dir) } ;
90+
91+ let how = OpenHow {
92+ flags : ( libc:: O_RDONLY | libc:: O_CLOEXEC ) as u64 ,
93+ mode : 0 ,
94+ resolve : RESOLVE_BENEATH ,
95+ } ;
96+
97+ // SAFETY: `dirfd` is a valid open directory fd; `rel_c` is a valid
98+ // NUL-terminated C string; `&how` points to a live, correctly laid-out
99+ // `struct open_how` and we pass its exact size. The return value is
100+ // checked below before any use.
101+ let ret = unsafe {
102+ libc:: syscall (
103+ SYS_OPENAT2 ,
104+ dirfd. as_raw_fd ( ) ,
105+ rel_c. as_ptr ( ) ,
106+ & how as * const OpenHow ,
107+ size_of :: < OpenHow > ( ) ,
108+ )
109+ } ;
110+
111+ if ret < 0 {
112+ let err = io:: Error :: last_os_error ( ) ;
113+ if err. raw_os_error ( ) == Some ( libc:: ENOSYS ) {
114+ SUPPORTED . store ( 2 , Ordering :: Relaxed ) ;
115+ return Ok ( None ) ;
116+ }
117+ return Err ( err) ;
118+ }
119+
120+ SUPPORTED . store ( 1 , Ordering :: Relaxed ) ;
121+ // SAFETY: `ret` is a fresh, owned, valid fd returned by openat2.
122+ let file = unsafe { std:: fs:: File :: from_raw_fd ( ret as libc:: c_int ) } ;
123+ Ok ( Some ( file) )
124+ }
125+ }
126+
13127/// Serves static files rooted at a directory.
14128///
15129/// Security: request paths are percent-decoded, normalized, and any `..`
@@ -104,6 +218,29 @@ impl StaticFiles {
104218 }
105219 }
106220
221+ /// Open `path` (which is `self.root` + validated segments) for reading,
222+ /// confined beneath the root so no symlink/rename race can escape. Returns
223+ /// Ok(None) when confined open is unavailable (non-Linux build, or a kernel
224+ /// without openat2) so the caller uses the canonicalize-based fallback.
225+ #[ cfg( all( feature = "hardened-fs" , target_os = "linux" ) ) ]
226+ fn open_confined ( & self , path : & Path ) -> io:: Result < Option < fs:: File > > {
227+ // `resolve` builds `root + segments`, so stripping the root yields the
228+ // relative path to open beneath the root dirfd. If it somehow fails,
229+ // fall back rather than risk an unconfined open.
230+ let Ok ( rel) = path. strip_prefix ( & self . root ) else {
231+ return Ok ( None ) ;
232+ } ;
233+ confined:: open_beneath ( & self . root , rel)
234+ }
235+
236+ /// Fallback stub on non-Linux builds or without `hardened-fs`: confined open
237+ /// is unavailable, so always fall back to the canonicalize-based path.
238+ #[ cfg( not( all( feature = "hardened-fs" , target_os = "linux" ) ) ) ]
239+ fn open_confined ( & self , path : & Path ) -> io:: Result < Option < fs:: File > > {
240+ let _ = path;
241+ Ok ( None )
242+ }
243+
107244 fn serve ( & self , req : & Request ) -> Response {
108245 if !matches ! ( req. method( ) , Method :: Get | Method :: Head ) {
109246 return Response :: status ( StatusCode :: METHOD_NOT_ALLOWED ) . header ( "Allow" , "GET, HEAD" ) ;
@@ -134,16 +271,51 @@ impl StaticFiles {
134271 path. push ( & self . index ) ;
135272 }
136273
137- let meta = match fs:: metadata ( & path) {
138- Ok ( m) if m. is_file ( ) => m,
139- _ => return Response :: status ( StatusCode :: NOT_FOUND ) ,
274+ // Acquire the file and its metadata race-free. On Linux with
275+ // `hardened-fs`, `open_confined` opens beneath the root via
276+ // `openat2(RESOLVE_BENEATH)`, so open+confinement is a single atomic
277+ // syscall (no TOCTOU window). When confined open is unavailable it
278+ // returns `Ok(None)` and we take the historical canonicalize fallback.
279+ let ( file, meta) = match self . open_confined ( & path) {
280+ // Confined open succeeded: the open IS the confinement, so we do NOT
281+ // re-run `within_root`. Just fstat the fd to confirm it is a file.
282+ Ok ( Some ( file) ) => {
283+ let meta = match file. metadata ( ) {
284+ Ok ( m) if m. is_file ( ) => m,
285+ _ => return Response :: status ( StatusCode :: NOT_FOUND ) ,
286+ } ;
287+ ( file, meta)
288+ }
289+ // Fallback path (non-Linux, or a kernel without openat2): do exactly
290+ // what the code did historically — is_file stat, `within_root`
291+ // confinement, then a plain open.
292+ Ok ( None ) => {
293+ let meta = match fs:: metadata ( & path) {
294+ Ok ( m) if m. is_file ( ) => m,
295+ _ => return Response :: status ( StatusCode :: NOT_FOUND ) ,
296+ } ;
297+ if !self . within_root ( & path) {
298+ // A target that escapes the root (e.g. via a symlink) is
299+ // reported as `404` — indistinguishable from a missing file
300+ // — so it cannot be used as an existence/symlink oracle,
301+ // matching the dotfile policy.
302+ return Response :: status ( StatusCode :: NOT_FOUND ) ;
303+ }
304+ let file = match fs:: File :: open ( & path) {
305+ Ok ( f) => f,
306+ Err ( e) if e. kind ( ) == io:: ErrorKind :: NotFound => {
307+ return Response :: status ( StatusCode :: NOT_FOUND ) ;
308+ }
309+ Err ( _) => return Response :: status ( StatusCode :: INTERNAL_SERVER_ERROR ) ,
310+ } ;
311+ ( file, meta)
312+ }
313+ // A confined-open error: missing, or a confinement rejection
314+ // (ELOOP/EXDEV/ENOTDIR from an escaping symlink/`..`), or a
315+ // permission error. All are reported as `404` — indistinguishable
316+ // from a missing file — so an escape cannot be used as an oracle.
317+ Err ( _) => return Response :: status ( StatusCode :: NOT_FOUND ) ,
140318 } ;
141- if !self . within_root ( & path) {
142- // A target that escapes the root (e.g. via a symlink) is reported as
143- // `404` — indistinguishable from a missing file — so it cannot be
144- // used as an existence/symlink oracle, matching the dotfile policy.
145- return Response :: status ( StatusCode :: NOT_FOUND ) ;
146- }
147319
148320 let content_type = mime:: from_path ( path. to_string_lossy ( ) . as_ref ( ) ) ;
149321 let len = meta. len ( ) ;
@@ -164,19 +336,11 @@ impl StaticFiles {
164336 return resp;
165337 }
166338
167- // Open the file once, here, so a permission/existence error becomes a
168- // clean status BEFORE any headers are committed. The fd is shared via
169- // `Arc` and the body is streamed off it in bounded chunks (200 and 206
170- // alike) — the whole file is never buffered. A HEAD still produces a
171- // `Body::File` so `Content-Length` is correct, but the engines skip the
172- // read for bodyless responses.
173- let file = match fs:: File :: open ( & path) {
174- Ok ( f) => f,
175- Err ( e) if e. kind ( ) == io:: ErrorKind :: NotFound => {
176- return Response :: status ( StatusCode :: NOT_FOUND ) ;
177- }
178- Err ( _) => return Response :: status ( StatusCode :: INTERNAL_SERVER_ERROR ) ,
179- } ;
339+ // The file was opened above (confined on Linux/`hardened-fs`, plain
340+ // otherwise). The fd is shared via `Arc` and the body is streamed off it
341+ // in bounded chunks (200 and 206 alike) — the whole file is never
342+ // buffered. A HEAD still produces a `Body::File` so `Content-Length` is
343+ // correct, but the engines skip the read for bodyless responses.
180344 let file = Arc :: new ( file) ;
181345
182346 // Range handling (single range only). A satisfiable range streams exactly
@@ -458,4 +622,85 @@ mod tests {
458622 let _ = fs:: remove_dir_all ( & root) ;
459623 assert_eq ! ( sf. canonical_root( ) . map( |p| p. to_owned( ) ) , first) ;
460624 }
625+
626+ // --- confined-open (openat2 RESOLVE_BENEATH) tests -----------------------
627+
628+ #[ cfg( all( feature = "hardened-fs" , target_os = "linux" ) ) ]
629+ #[ test]
630+ fn confined_serves_in_root_file ( ) {
631+ // A normal in-root file is served (200) through the confined open path.
632+ let root = scratch_dir ( "confined-ok" ) ;
633+ fs:: write ( root. join ( "hello.txt" ) , b"hi" ) . unwrap ( ) ;
634+ let sf = StaticFiles :: new ( & root) ;
635+ assert_eq ! ( sf. serve( & get( "/hello.txt" ) ) . status_code( ) . code( ) , 200 ) ;
636+ let _ = fs:: remove_dir_all ( & root) ;
637+ }
638+
639+ #[ cfg( all( feature = "hardened-fs" , target_os = "linux" ) ) ]
640+ #[ test]
641+ fn confined_symlink_escape_is_404 ( ) {
642+ // A symlink inside the root pointing OUTSIDE the root must be rejected
643+ // by the confined open (EXDEV/ELOOP) and reported as 404.
644+ let base = scratch_dir ( "confined-escape" ) ;
645+ let root = base. join ( "root" ) ;
646+ let outside = base. join ( "outside" ) ;
647+ fs:: create_dir_all ( & root) . unwrap ( ) ;
648+ fs:: create_dir_all ( & outside) . unwrap ( ) ;
649+
650+ let secret = outside. join ( "secret.txt" ) ;
651+ fs:: write ( & secret, b"top secret" ) . unwrap ( ) ;
652+ let link = root. join ( "leak.txt" ) ;
653+ std:: os:: unix:: fs:: symlink ( & secret, & link) . unwrap ( ) ;
654+
655+ let sf = StaticFiles :: new ( & root) ;
656+ assert_eq ! ( sf. serve( & get( "/leak.txt" ) ) . status_code( ) . code( ) , 404 ) ;
657+ let _ = fs:: remove_dir_all ( & base) ;
658+ }
659+
660+ #[ cfg( all( feature = "hardened-fs" , target_os = "linux" ) ) ]
661+ #[ test]
662+ fn confined_allows_in_root_symlink ( ) {
663+ // RESOLVE_BENEATH still permits legitimate symlinks that stay inside the
664+ // root: root/link.txt -> real.txt (both in root) must be served (200).
665+ let root = scratch_dir ( "confined-inlink" ) ;
666+ fs:: write ( root. join ( "real.txt" ) , b"payload" ) . unwrap ( ) ;
667+ std:: os:: unix:: fs:: symlink ( "real.txt" , root. join ( "link.txt" ) ) . unwrap ( ) ;
668+
669+ let sf = StaticFiles :: new ( & root) ;
670+ assert_eq ! ( sf. serve( & get( "/link.txt" ) ) . status_code( ) . code( ) , 200 ) ;
671+ let _ = fs:: remove_dir_all ( & root) ;
672+ }
673+
674+ #[ cfg( all( feature = "hardened-fs" , target_os = "linux" ) ) ]
675+ #[ test]
676+ fn open_confined_unit ( ) {
677+ // Direct unit test of open_confined / confined::open_beneath: an
678+ // escaping relative path is rejected (Err), a valid one opens (Ok(Some)).
679+ let base = scratch_dir ( "confined-unit" ) ;
680+ let root = base. join ( "root" ) ;
681+ let outside = base. join ( "outside" ) ;
682+ fs:: create_dir_all ( & root) . unwrap ( ) ;
683+ fs:: create_dir_all ( & outside) . unwrap ( ) ;
684+ fs:: write ( root. join ( "real.txt" ) , b"payload" ) . unwrap ( ) ;
685+ fs:: write ( outside. join ( "secret.txt" ) , b"secret" ) . unwrap ( ) ;
686+ // root/leak.txt -> ../outside/secret.txt (escapes the root).
687+ std:: os:: unix:: fs:: symlink ( outside. join ( "secret.txt" ) , root. join ( "leak.txt" ) ) . unwrap ( ) ;
688+
689+ let sf = StaticFiles :: new ( & root) ;
690+
691+ // A valid in-root file opens.
692+ let ok = sf. open_confined ( & root. join ( "real.txt" ) ) . unwrap ( ) ;
693+ assert ! ( ok. is_some( ) ) ;
694+
695+ // An escaping symlink is rejected — openat2 returns an error (not None,
696+ // which would signal an unsupported kernel).
697+ let escaped = sf. open_confined ( & root. join ( "leak.txt" ) ) ;
698+ assert ! ( escaped. is_err( ) ) ;
699+
700+ // And at the raw layer, `..` escaping the root is rejected too.
701+ let up = confined:: open_beneath ( & root, Path :: new ( "../outside/secret.txt" ) ) ;
702+ assert ! ( up. is_err( ) ) ;
703+
704+ let _ = fs:: remove_dir_all ( & base) ;
705+ }
461706}
0 commit comments