Skip to main content

faultbox/
secure_fs.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Creating report files and directories that only their owner can read, and
4//! refusing artifact names that could escape the report directory.
5//!
6//! ## Why a report is owner-only
7//!
8//! A report is a *forensic* artifact. Redaction removes user content from the
9//! strings, but the things travelling beside them are not redactable and were
10//! never meant to be: a preserved artifact is a verbatim copy of the adopter's
11//! store, and a minidump is the crashed process's entire address space —
12//! encryption keys, session tokens, and whatever the user was working on, all
13//! of it live in memory at the moment of the fault.
14//!
15//! Left at the process umask, those land at `0644` inside a `0755` directory,
16//! which on any multi-user host means every local account can read them. So the
17//! recorder creates its own directories at `0700` and its own files at `0600`,
18//! and never widens either.
19//!
20//! ## Why temporary files are created exclusively
21//!
22//! Every durable write goes through a temporary sibling. `File::create` on a
23//! predictable name follows a symlink planted there first, so an attacker who
24//! can write to the reports directory could redirect the recorder's own write —
25//! running as the reporting process — into a file of their choosing.
26//!
27//! `create_new(true)` is `O_CREAT | O_EXCL`, which POSIX requires to fail on a
28//! symlink, so the plant is refused rather than followed. The names are also
29//! unpredictable, so squatting them to deny service is guesswork rather than
30//! arithmetic.
31//!
32//! ## Platform coverage
33//!
34//! The mode bits are a unix mechanism, and that is where this is enforced. On
35//! Windows a created file or directory takes the inherited ACL of its parent,
36//! and this crate does not set an explicit DACL — so on Windows the reports
37//! directory is exactly as private as the location the adopter chose for it.
38//! Under a user profile (`%LOCALAPPDATA%`) that is already per-user; somewhere
39//! world-writable it is not, and no code here changes that.
40//!
41//! Exclusive creation, and therefore the symlink-plant refusal, applies on every
42//! platform.
43
44use std::io;
45use std::path::{Path, PathBuf};
46
47/// Mode for a directory the recorder creates: owner-only.
48#[cfg(unix)]
49const DIR_MODE: u32 = 0o700;
50/// Mode for a file the recorder creates: owner read/write.
51#[cfg(unix)]
52const FILE_MODE: u32 = 0o600;
53
54/// Create `path` and any missing parents, owner-only.
55///
56/// An already-existing directory is left as it is — the reports directory is
57/// the adopter's to configure, and silently rewriting the permissions of a path
58/// they chose would be a surprise. [`harden_dir`] tightens the directories the
59/// recorder itself owns.
60pub(crate) fn create_dir_all_private(path: &Path) -> io::Result<()> {
61    #[cfg(unix)]
62    {
63        use std::os::unix::fs::DirBuilderExt as _;
64        std::fs::DirBuilder::new()
65            .recursive(true)
66            .mode(DIR_MODE)
67            .create(path)
68    }
69    #[cfg(not(unix))]
70    {
71        std::fs::create_dir_all(path)
72    }
73}
74
75/// Narrow an existing directory to owner-only.
76///
77/// Applied to report *group* directories, which belong entirely to the recorder
78/// — including ones a previous version created at the umask default, whose
79/// contents would otherwise stay world-readable forever. Best effort: a
80/// filesystem without unix modes simply has nothing to do.
81pub(crate) fn harden_dir(path: &Path) {
82    #[cfg(unix)]
83    {
84        use std::os::unix::fs::PermissionsExt as _;
85        let Ok(meta) = std::fs::metadata(path) else {
86            return;
87        };
88        let mode = meta.permissions().mode();
89        if mode & 0o7777 & !DIR_MODE != 0 {
90            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(DIR_MODE));
91        }
92    }
93    #[cfg(not(unix))]
94    {
95        let _ = path;
96    }
97}
98
99/// Create `path` for writing, owner-only, failing if it already exists.
100///
101/// Exclusive creation is what makes this safe against a pre-planted symlink;
102/// callers use it for temporary siblings whose names they own.
103pub(crate) fn create_new_private(path: &Path) -> io::Result<std::fs::File> {
104    let mut options = std::fs::OpenOptions::new();
105    options.write(true).create_new(true);
106    #[cfg(unix)]
107    {
108        use std::os::unix::fs::OpenOptionsExt as _;
109        options.mode(FILE_MODE);
110    }
111    options.open(path)
112}
113
114/// Narrow an existing file to owner-only. Used for files created by something
115/// other than [`create_new_private`] — a minidump the IPC server opened, or a
116/// report written by an earlier version of this crate.
117pub(crate) fn harden_file(path: &Path) {
118    #[cfg(unix)]
119    {
120        use std::os::unix::fs::PermissionsExt as _;
121        let Ok(meta) = std::fs::metadata(path) else {
122            return;
123        };
124        if meta.permissions().mode() & 0o7777 & !FILE_MODE != 0 {
125            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE));
126        }
127    }
128    #[cfg(not(unix))]
129    {
130        let _ = path;
131    }
132}
133
134/// The infix marking a path as one of the recorder's own temporaries.
135///
136/// Reserved, and enforced by [`validate_artifact_name`]: abandoned temporaries
137/// are collected by a later capture, which recognises them by this substring. If
138/// an artifact could carry it, a preserved snapshot named `db.tmp.1` would be
139/// swept away as wreckage. Distinctive enough that nothing incidental matches.
140pub const TEMP_INFIX: &str = ".faultbox-";
141
142/// A process-local counter making temporary names unique within this process,
143/// so two concurrent captures in one process cannot pick the same path.
144static TEMP_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
145
146/// Create a temporary file beside `final_path`, named `<final>.<tag>.<unique>`.
147///
148/// Returns the open handle and the path, so the caller can write, sync, and
149/// rename. The name embeds process id, a monotonic counter and a clock reading;
150/// exclusive creation retries on the (vanishingly unlikely, or adversarial)
151/// collision, so a squatter can delay a write but never redirect one.
152pub(crate) fn create_temp_beside(
153    final_path: &Path,
154    tag: &str,
155) -> io::Result<(std::fs::File, PathBuf)> {
156    let mut last = None;
157    for _ in 0..64 {
158        let path = temp_path(final_path, tag);
159        match create_new_private(&path) {
160            Ok(file) => return Ok((file, path)),
161            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
162            Err(e) => return Err(e),
163        }
164    }
165    Err(last.unwrap_or_else(|| {
166        io::Error::new(
167            io::ErrorKind::AlreadyExists,
168            "could not find a free temporary name",
169        )
170    }))
171}
172
173/// A unique sibling path for `final_path`. Split out so the directory-copy path,
174/// which needs a *directory* rather than a file, can share the naming.
175pub(crate) fn temp_path(final_path: &Path, tag: &str) -> PathBuf {
176    let sequence = TEMP_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
177    let mut name = final_path.file_name().map_or_else(
178        || std::ffi::OsString::from("faultbox"),
179        std::ffi::OsStr::to_os_string,
180    );
181    name.push(format!(
182        "{TEMP_INFIX}{tag}.{}.{sequence}.{}",
183        std::process::id(),
184        crate::now_ms()
185    ));
186    match final_path.parent() {
187        Some(parent) => parent.join(name),
188        None => PathBuf::from(name),
189    }
190}
191
192/// Create a temporary *directory* beside `final_path`, owner-only and
193/// exclusively — the recursive-copy counterpart to [`create_temp_beside`].
194pub(crate) fn create_temp_dir_beside(final_path: &Path, tag: &str) -> io::Result<PathBuf> {
195    let mut last = None;
196    for _ in 0..64 {
197        let path = temp_path(final_path, tag);
198        match create_dir_exclusive(&path) {
199            Ok(()) => return Ok(path),
200            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
201            Err(e) => return Err(e),
202        }
203    }
204    Err(last.unwrap_or_else(|| {
205        io::Error::new(
206            io::ErrorKind::AlreadyExists,
207            "could not find a free temporary name",
208        )
209    }))
210}
211
212/// Create exactly `path` as an owner-only directory, failing if it exists.
213pub(crate) fn create_dir_exclusive(path: &Path) -> io::Result<()> {
214    #[cfg(unix)]
215    {
216        use std::os::unix::fs::DirBuilderExt as _;
217        std::fs::DirBuilder::new()
218            .recursive(false)
219            .mode(DIR_MODE)
220            .create(path)
221    }
222    #[cfg(not(unix))]
223    {
224        std::fs::DirBuilder::new().recursive(false).create(path)
225    }
226}
227
228/// Names the recorder keeps for its own state inside a report directory. An
229/// artifact may not take one of them, or preserving would overwrite the report
230/// it is attached to — or the lock protecting it.
231const RESERVED_NAMES: &[&str] = &[".lock", "report.json", "latest.json", "minidump"];
232
233/// Validate an artifact name supplied by the adopter.
234///
235/// The name is joined onto the report directory, so an unchecked one is a path
236/// traversal: `preserve(.., "../../escaped.db", ..)` writes outside the reports
237/// directory entirely, and because committing an artifact removes whatever sits
238/// at the destination first, it *deletes* outside it too — a whole directory
239/// tree, in the case of a preserved store.
240///
241/// Names in real use are plain file names (`store.corrupt`, `snap.db`), so the
242/// rule is simply that: one path component of ordinary file-name characters. It
243/// also rules out absolute paths, Windows drive prefixes and alternate data
244/// streams, embedded NULs, and the reserved names above.
245pub fn validate_artifact_name(name: &str) -> io::Result<()> {
246    let reject = |why: &str| {
247        Err(io::Error::new(
248            io::ErrorKind::InvalidInput,
249            format!("invalid artifact name {name:?}: {why}"),
250        ))
251    };
252
253    if name.is_empty() {
254        return reject("empty");
255    }
256    if name.len() > 255 {
257        return reject("longer than 255 bytes");
258    }
259    if name == "." || name == ".." {
260        return reject("a path traversal component");
261    }
262    if name.starts_with('.') {
263        return reject("names beginning with a dot are reserved");
264    }
265    if !name
266        .bytes()
267        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
268    {
269        return reject("must be a single path component of ASCII letters, digits, '.', '_' or '-'");
270    }
271    if RESERVED_NAMES.iter().any(|r| name.eq_ignore_ascii_case(r)) {
272        return reject("reserved for the recorder's own files");
273    }
274    if name.contains(TEMP_INFIX) {
275        return reject("reserved: it marks the recorder's own temporary files");
276    }
277    Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn traversal_and_absolute_names_are_refused() {
286        for name in [
287            "../escape",
288            "../../escape",
289            "a/b",
290            "a\\b",
291            "/etc/passwd",
292            "C:\\windows",
293            "stream:ads",
294            ".",
295            "..",
296            "",
297            ".hidden",
298        ] {
299            assert!(
300                validate_artifact_name(name).is_err(),
301                "{name:?} must be refused"
302            );
303        }
304    }
305
306    #[test]
307    fn the_recorders_own_files_cannot_be_claimed() {
308        for name in [".lock", "report.json", "latest.json", "REPORT.JSON"] {
309            assert!(
310                validate_artifact_name(name).is_err(),
311                "{name:?} must be refused"
312            );
313        }
314    }
315
316    #[test]
317    fn a_nul_byte_cannot_hide_inside_a_name() {
318        assert!(validate_artifact_name("snap\0.db").is_err());
319    }
320
321    #[test]
322    fn ordinary_artifact_names_are_accepted() {
323        for name in [
324            "snap",
325            "store.corrupt",
326            "main.db",
327            "seg-0_1.bin",
328            // Looks temporary, is not reserved, and must keep working.
329            "db.tmp.1",
330            "restore.incoming.2",
331        ] {
332            assert!(
333                validate_artifact_name(name).is_ok(),
334                "{name:?} must be accepted"
335            );
336        }
337    }
338
339    /// An artifact carrying the temporary marker would be collected as
340    /// wreckage by a later capture's sweep, so it cannot be allowed to.
341    #[test]
342    fn an_artifact_cannot_carry_the_temporary_marker() {
343        assert!(validate_artifact_name("snap.faultbox-incoming.1").is_err());
344        assert!(
345            temp_path(Path::new("/r/snap.db"), "incoming")
346                .file_name()
347                .unwrap()
348                .to_string_lossy()
349                .contains(TEMP_INFIX),
350            "the sweep and the namer must agree on the marker"
351        );
352    }
353
354    #[cfg(unix)]
355    #[test]
356    fn created_directories_and_files_are_owner_only() {
357        use std::os::unix::fs::PermissionsExt as _;
358
359        let tmp = tempfile::tempdir().unwrap();
360        let dir = tmp.path().join("nested/group");
361        create_dir_all_private(&dir).unwrap();
362        assert_eq!(
363            std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
364            0o700
365        );
366
367        let (file, path) = create_temp_beside(&dir.join("report.json"), "tmp").unwrap();
368        drop(file);
369        assert_eq!(
370            std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777,
371            0o600
372        );
373    }
374
375    #[cfg(unix)]
376    #[test]
377    fn a_planted_symlink_is_refused_rather_than_followed() {
378        let tmp = tempfile::tempdir().unwrap();
379        let victim = tmp.path().join("victim");
380        std::fs::write(&victim, b"original").unwrap();
381
382        let planted = tmp.path().join("planted");
383        std::os::unix::fs::symlink(&victim, &planted).unwrap();
384
385        let err = create_new_private(&planted).expect_err("must refuse a symlink");
386        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
387        assert_eq!(
388            std::fs::read(&victim).unwrap(),
389            b"original",
390            "the target must not have been written through"
391        );
392    }
393
394    #[cfg(unix)]
395    #[test]
396    fn harden_dir_narrows_a_world_readable_directory() {
397        use std::os::unix::fs::PermissionsExt as _;
398
399        let tmp = tempfile::tempdir().unwrap();
400        let dir = tmp.path().join("legacy");
401        std::fs::create_dir(&dir).unwrap();
402        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
403
404        harden_dir(&dir);
405        assert_eq!(
406            std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
407            0o700
408        );
409    }
410
411    #[test]
412    fn temporary_names_do_not_repeat() {
413        let base = Path::new("/tmp/reports/report.json");
414        let a = temp_path(base, "tmp");
415        let b = temp_path(base, "tmp");
416        assert_ne!(a, b, "two temporaries must never collide");
417        assert_eq!(a.parent(), base.parent(), "staged beside the destination");
418    }
419}