Skip to content

Commit c1afcea

Browse files
MagicalTuxclaude
andcommitted
static_files: close TOCTOU/symlink-escape with race-free confined open
The static-file handler had a TOCTOU/symlink-escape window: between the `within_root` canonicalize check and `File::open`, a path component could be swapped to a symlink so the opened file escapes the root. Close it with a race-free confined open on Linux via `openat2(2)` with `RESOLVE_BENEATH`, gated behind a new default feature `hardened-fs` (reuses the already-optional `libc` dep). This makes open+confinement a single atomic syscall: `..` above the root, absolute symlinks, and out-of-tree symlink targets all fail, while in-root symlinks still resolve (matching prior semantics). - Cargo.toml: add `hardened-fs = ["dep:libc"]`, include in `default`. - lib.rs: `hardened-fs` is the second scoped `unsafe` user besides `privdrop`; `forbid(unsafe_code)` unless either is on, else `deny`. - static_files.rs: private `confined` module (Linux+hardened-fs) doing the `openat2` call with a cached ENOSYS probe; `StaticFiles::open_confined`; `serve` now acquires the file confined-first, falling back to the historical canonicalize + `File::open` when unavailable (non-Linux / old kernel). Confinement rejections (ELOOP/EXDEV/ENOTDIR) and missing files alike map to 404 so they cannot be used as an existence/symlink oracle. - Tests: confined 200 for in-root file, 404 for escaping symlink, 200 for in-root symlink, plus a direct unit test of the escape/valid cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e82373f commit c1afcea

3 files changed

Lines changed: 284 additions & 29 deletions

File tree

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ required-features = ["cli"]
2020
[features]
2121
# The default build is a complete, self-contained HTTPS server CLI: a worker
2222
# thread pool, TLS, HTTP/2, response compression, and TOML config loading.
23-
default = ["cli", "rt-threadpool", "tls", "compress", "h2", "h3", "router"]
23+
default = ["cli", "rt-threadpool", "tls", "compress", "h2", "h3", "router", "hardened-fs"]
2424

2525
# TLS / HTTPS support, built on the sans-I/O `purecrypto::tls` engine.
2626
tls = ["dep:purecrypto"]
@@ -44,6 +44,11 @@ router = []
4444
# Unix only; needs `libc` for the syscalls. Pulled into `cli` so the default
4545
# binary can `--user`/`--chroot`.
4646
privdrop = ["dep:libc"]
47+
# Race-free confined file opening beneath the static-file root via
48+
# `openat2(RESOLVE_BENEATH)` on Linux; no-op fallback elsewhere. Closes a
49+
# static-file TOCTOU/symlink-escape window by making open+confinement a single
50+
# atomic syscall. Reuses the (already optional) `libc` dependency.
51+
hardened-fs = ["dep:libc"]
4752
# Interop with the `http` crate: `From`/`TryFrom` conversions between httpsd's
4853
# `Method`/`StatusCode`/`Version`/`Headers`/`Request`/`Response` and the
4954
# ecosystem-standard `http` types (and `IntoResponse for http::Response` when

src/lib.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@
3636
//! - `cli` — build the `httpsd` binary.
3737
//! - `rt-threadpool` (default), `rt-tokio`, `rt-mio` — runtime drivers.
3838
39-
// The crate is unsafe-free except for the optional `privdrop` module, whose
40-
// privilege-dropping syscalls (setuid/setgid/chroot via libc) require `unsafe`.
41-
// When that feature is off we keep the hard `forbid`; when it is on we downgrade
42-
// to `deny` so the single module can opt back in with a scoped `allow`.
43-
#![cfg_attr(not(feature = "privdrop"), forbid(unsafe_code))]
44-
#![cfg_attr(feature = "privdrop", deny(unsafe_code))]
39+
// The crate is unsafe-free except for two optional modules that need `unsafe`
40+
// for libc syscalls: the `privdrop` module (setuid/setgid/chroot) and the
41+
// `hardened-fs` confined-open module (`openat2(RESOLVE_BENEATH)` in
42+
// `static_files`). When neither feature is on we keep the hard `forbid`; when
43+
// either is on we downgrade to `deny` so those scoped modules can opt back in
44+
// with a local `allow`.
45+
#![cfg_attr(
46+
not(any(feature = "privdrop", feature = "hardened-fs")),
47+
forbid(unsafe_code)
48+
)]
49+
#![cfg_attr(any(feature = "privdrop", feature = "hardened-fs"), deny(unsafe_code))]
4550
#![warn(missing_docs)]
4651

4752
pub mod error;

src/static_files.rs

Lines changed: 267 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,120 @@ use crate::handler::Handler;
1010
use crate::mime;
1111
use 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

Comments
 (0)