Skip to main content

csaf_core/
path_security.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Path-traversal-safe join primitive (CWE-22 guard).
5//!
6//! `safe_join` resolves a caller-supplied relative path against a base
7//! directory at the string layer, rejecting any input that would escape
8//! the base via `..`, an absolute path, a drive / UNC prefix, or a NUL
9//! byte. It is the companion to the capability-based `cap-std` migration
10//! described in `skills/rust-path-security.md`; until that lands it gives
11//! every external-input path junction a single, audited choke point.
12
13use std::ffi::OsStr;
14use std::path::{Component, Path, PathBuf};
15
16/// Join `input` onto `base`, returning `None` if `input` would escape
17/// `base`.
18///
19/// Accepts only relative inputs that stay within `base`. The returned
20/// path is guaranteed to start with `base` and to contain no `..`
21/// ([`Component::ParentDir`]) component.
22///
23/// Rejected (returns `None`):
24/// - any input containing a NUL byte,
25/// - absolute paths and Windows drive / UNC prefixes
26///   ([`Component::RootDir`], [`Component::Prefix`]),
27/// - any `..` that pops above `base`.
28///
29/// Accepted (returns `Some`):
30/// - `.` and the empty string resolve to `base` itself,
31/// - balanced `a/../b` walks that stay at or below `base`.
32///
33/// The check is purely lexical — it does not touch the filesystem and
34/// does not resolve symlinks. Symlink-boundary enforcement belongs to
35/// the `cap-std` layer.
36#[must_use]
37pub fn safe_join(base: &Path, input: &str) -> Option<PathBuf> {
38    // A NUL byte cannot appear in any legitimate path; reject outright
39    // so a truncation attack (CVE-2007-0883 class) never reaches a
40    // syscall.
41    if input.contains('\0') {
42        return None;
43    }
44
45    let mut stack: Vec<&OsStr> = Vec::new();
46    for component in Path::new(input).components() {
47        match component {
48            Component::CurDir => {},
49            Component::Normal(segment) => stack.push(segment),
50            // A `..` with nothing left to pop would escape `base`.
51            Component::ParentDir => {
52                stack.pop()?;
53            },
54            // Absolute roots and drive / UNC prefixes are never allowed
55            // against a confined base.
56            Component::RootDir | Component::Prefix(_) => return None,
57        }
58    }
59
60    let mut resolved = base.to_path_buf();
61    for segment in stack {
62        resolved.push(segment);
63    }
64    Some(resolved)
65}
66
67/// Whether `input` is a safe relative subpath: no `..` traversal, not
68/// absolute, no drive / UNC prefix, no NUL byte.
69///
70/// Equivalent to `safe_join(base, input).is_some()` for any `base`; use
71/// it where the caller only needs a yes/no decision rather than the
72/// joined path (e.g. validating an HTTP path parameter before matching
73/// it against a static whitelist).
74#[must_use]
75pub fn is_safe_relative_path(input: &str) -> bool {
76    safe_join(Path::new(""), input).is_some()
77}
78
79#[cfg(test)]
80#[allow(clippy::unwrap_used, clippy::panic)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn is_safe_relative_path_accepts_plain_subpaths() {
86        assert!(is_safe_relative_path("css/custom.css"));
87        assert!(is_safe_relative_path("js/theme.js"));
88        assert!(is_safe_relative_path("fonts/roboto/roboto-regular.ttf"));
89        assert!(is_safe_relative_path("."));
90        assert!(is_safe_relative_path(""));
91    }
92
93    #[test]
94    fn is_safe_relative_path_rejects_traversal_and_absolute() {
95        assert!(!is_safe_relative_path("../secret"));
96        assert!(!is_safe_relative_path("css/../../etc/passwd"));
97        assert!(!is_safe_relative_path("/etc/passwd"));
98        assert!(!is_safe_relative_path("a\0b"));
99    }
100
101    #[test]
102    fn safe_join_confines_to_base() {
103        let base = Path::new("/srv/data");
104        assert_eq!(
105            safe_join(base, "2026/001/a.json"),
106            Some(PathBuf::from("/srv/data/2026/001/a.json"))
107        );
108        assert_eq!(safe_join(base, "2026/.."), Some(PathBuf::from("/srv/data")));
109        assert_eq!(safe_join(base, "../escape"), None);
110        assert_eq!(safe_join(base, "/etc/passwd"), None);
111    }
112}