Skip to main content

rama_utils/fs/
sanitize.rs

1//! Lexical path sanitization used to guard against path-traversal attacks.
2
3use std::ffi::OsStr;
4use std::fmt;
5use std::path::{Component, Path, PathBuf};
6
7/// Error returned when a path is rejected as unsafe.
8///
9/// Converts into an [`std::io::Error`] with
10/// [`std::io::ErrorKind::InvalidInput`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum UnsafePathError {
14    /// The path contained a parent-directory (`..`) component, which can be
15    /// used to escape the intended directory ("dot-dot" traversal).
16    ParentDir,
17    /// An absolute path (or one carrying a root/drive/UNC prefix) was supplied
18    /// where only a relative path is permitted (e.g. when confined to a root).
19    Absolute,
20    /// A component matched a reserved device name (e.g. Windows `CON`, `NUL`,
21    /// `COM1`), which has special and surprising filesystem semantics.
22    ReservedName,
23    /// The fully resolved path escaped the configured root directory, e.g.
24    /// through a symbolic link pointing outside of it.
25    EscapesRoot,
26}
27
28impl fmt::Display for UnsafePathError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        let msg = match self {
31            Self::ParentDir => "path contains a parent-directory (`..`) component",
32            Self::Absolute => "path is absolute but only a relative path is allowed",
33            Self::ReservedName => "path contains a reserved device name",
34            Self::EscapesRoot => "path escapes the permitted root directory",
35        };
36        f.write_str(msg)
37    }
38}
39
40impl std::error::Error for UnsafePathError {}
41
42impl From<UnsafePathError> for std::io::Error {
43    fn from(err: UnsafePathError) -> Self {
44        Self::new(std::io::ErrorKind::InvalidInput, err)
45    }
46}
47
48/// Validate `path` and return a cleaned, lexically-equivalent path that is safe
49/// from "dot-dot" traversal.
50///
51/// `.` (current-dir) components are dropped and `..` (parent-dir) components are
52/// rejected ([`UnsafePathError::ParentDir`]). Components that smuggle in a path
53/// prefix (e.g. a Windows drive letter in `foo/c:/bar`) and reserved device
54/// names (Windows `CON`, `NUL`, `COM1`, ...) are rejected too.
55///
56/// Absolute paths are permitted: a leading root/prefix is preserved. The
57/// guarantee is only that the result never points *above* its own starting
58/// point. When the path must stay within a known directory, use a root-confined
59/// helper such as [`safe_write_in`](super::safe_write_in).
60///
61/// Note: this works on already-decoded paths. Percent-decoding (e.g. of an HTTP
62/// target like `%2e%2e%2f`) is the caller's responsibility and must happen
63/// *before* calling this function.
64pub fn sanitize_path(path: impl AsRef<Path>) -> Result<PathBuf, UnsafePathError> {
65    sanitize(path.as_ref(), false)
66}
67
68/// Like [`sanitize_path`] but additionally requires the path to be relative,
69/// rejecting absolute paths and drive/UNC prefixes ([`UnsafePathError::Absolute`]).
70///
71/// Returns the cleaned relative path with `.` components dropped; join it onto
72/// a trusted root directory to confine filesystem access to within that root.
73pub fn sanitize_relative_path(path: impl AsRef<Path>) -> Result<PathBuf, UnsafePathError> {
74    sanitize(path.as_ref(), true)
75}
76
77fn sanitize(path: &Path, relative_only: bool) -> Result<PathBuf, UnsafePathError> {
78    let mut out = PathBuf::new();
79    for component in path.components() {
80        match component {
81            Component::Prefix(_) | Component::RootDir => {
82                if relative_only {
83                    return Err(UnsafePathError::Absolute);
84                }
85                out.push(component.as_os_str());
86            }
87            Component::CurDir => {}
88            Component::ParentDir => return Err(UnsafePathError::ParentDir),
89            Component::Normal(name) => {
90                check_normal_component(name)?;
91                out.push(name);
92            }
93        }
94    }
95    Ok(out)
96}
97
98/// Validate a single `Normal` path component.
99fn check_normal_component(name: &OsStr) -> Result<(), UnsafePathError> {
100    // A normal component must not itself decompose into anything other than a
101    // single normal component. This rejects a smuggled prefix/root such as a
102    // Windows drive letter in `foo/c:/bar` (rama #204).
103    if !Path::new(name)
104        .components()
105        .all(|c| matches!(c, Component::Normal(_)))
106    {
107        return Err(UnsafePathError::Absolute);
108    }
109
110    // Reserved device names only have special semantics on Windows, where a
111    // valid Unix filename like `CON` would resolve to a device.
112    #[cfg(windows)]
113    if is_reserved_device_name(name) {
114        return Err(UnsafePathError::ReservedName);
115    }
116
117    Ok(())
118}
119
120/// Whether `name` matches a reserved Windows device name (e.g. `CON`, `NUL`,
121/// `COM1`, `LPT9`), independent of the current platform.
122#[must_use]
123pub fn is_reserved_device_name(name: impl AsRef<OsStr>) -> bool {
124    let name = name.as_ref();
125    #[cfg(windows)]
126    {
127        use std::os::windows::ffi::OsStrExt;
128        is_reserved_dos_name(|| name.encode_wide())
129    }
130    #[cfg(not(windows))]
131    {
132        let name = name.to_string_lossy();
133        is_reserved_dos_name(|| name.encode_utf16())
134    }
135}
136
137/// Check whether a component name matches a reserved Windows DOS device name.
138/// See: <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions>
139fn is_reserved_dos_name<F, I>(mut get_iter: F) -> bool
140where
141    F: FnMut() -> I,
142    I: Iterator<Item = u16>,
143{
144    const CON: [u16; 3] = [b'C' as u16, b'O' as u16, b'N' as u16];
145    const PRN: [u16; 3] = [b'P' as u16, b'R' as u16, b'N' as u16];
146    const AUX: [u16; 3] = [b'A' as u16, b'U' as u16, b'X' as u16];
147    const NUL: [u16; 3] = [b'N' as u16, b'U' as u16, b'L' as u16];
148    const CONIN: [u16; 6] = [
149        b'C' as u16,
150        b'O' as u16,
151        b'N' as u16,
152        b'I' as u16,
153        b'N' as u16,
154        b'$' as u16,
155    ];
156    const CONOUT: [u16; 7] = [
157        b'C' as u16,
158        b'O' as u16,
159        b'N' as u16,
160        b'O' as u16,
161        b'U' as u16,
162        b'T' as u16,
163        b'$' as u16,
164    ];
165
166    const COM: [u16; 3] = [b'C' as u16, b'O' as u16, b'M' as u16];
167    const LPT: [u16; 3] = [b'L' as u16, b'P' as u16, b'T' as u16];
168
169    const ZERO: u16 = b'0' as u16;
170    const NINE: u16 = b'9' as u16;
171    const SUPERSCRIPT_ONE: u16 = 0x00B9;
172    const SUPERSCRIPT_TWO: u16 = 0x00B2;
173    const SUPERSCRIPT_THREE: u16 = 0x00B3;
174
175    fn is_whitespace(c: u16) -> bool {
176        c <= 0x7F && ((c as u8).is_ascii_whitespace() || c == 0x000B)
177    }
178
179    let trimmed_len = get_iter()
180        .enumerate()
181        .take_while(|&(_idx, c)| c != b'.' as u16 && c != b':' as u16)
182        .filter(|&(_idx, c)| !is_whitespace(c))
183        .last()
184        .map(|(idx, _)| idx + 1)
185        .unwrap_or(0);
186
187    if trimmed_len > 7 {
188        return false;
189    }
190
191    let mut buf = [0u16; 7];
192    get_iter()
193        .take(trimmed_len)
194        .enumerate()
195        .for_each(|(i, c)| buf[i] = c);
196
197    for b in &mut buf {
198        if *b <= 0x7F {
199            *b = (*b as u8).to_ascii_uppercase() as u16;
200        }
201        if *b == SUPERSCRIPT_ONE {
202            *b = b'1' as u16;
203        }
204        if *b == SUPERSCRIPT_TWO {
205            *b = b'2' as u16;
206        }
207        if *b == SUPERSCRIPT_THREE {
208            *b = b'3' as u16;
209        }
210    }
211    let name = &buf[..trimmed_len];
212
213    if name == CON || name == PRN || name == AUX || name == NUL || name == CONIN || name == CONOUT {
214        return true;
215    }
216
217    if name.len() == 4 {
218        let prefix = &name[..3];
219        let suffix = name[3];
220
221        if (prefix == COM || prefix == LPT) && matches!(suffix, ZERO..=NINE) {
222            return true;
223        }
224    }
225
226    false
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn clean(path: &str) -> Result<PathBuf, UnsafePathError> {
234        sanitize_path(path)
235    }
236
237    #[test]
238    fn accepts_plain_relative_paths() {
239        assert_eq!(clean("a/b/c").unwrap(), PathBuf::from("a/b/c"));
240        assert_eq!(clean("foo.txt").unwrap(), PathBuf::from("foo.txt"));
241        assert_eq!(
242            clean("dir/file.bin").unwrap(),
243            PathBuf::from("dir/file.bin")
244        );
245    }
246
247    #[test]
248    fn drops_current_dir_components() {
249        assert_eq!(clean("./a/./b").unwrap(), PathBuf::from("a/b"));
250        assert_eq!(clean("a/./b/c").unwrap(), PathBuf::from("a/b/c"));
251    }
252
253    #[test]
254    fn rejects_parent_dir_traversal() {
255        for payload in [
256            "..",
257            "../",
258            "../etc/passwd",
259            "../../../../etc/passwd",
260            "foo/../bar",
261            "foo/../../bar",
262            "a/b/../../../c",
263            "dir/..",
264        ] {
265            assert_eq!(
266                clean(payload),
267                Err(UnsafePathError::ParentDir),
268                "expected `{payload}` to be rejected as traversal",
269            );
270        }
271    }
272
273    #[test]
274    fn preserves_absolute_paths_without_a_root() {
275        #[cfg(unix)]
276        {
277            assert_eq!(clean("/etc/hosts").unwrap(), PathBuf::from("/etc/hosts"));
278            assert_eq!(
279                clean("/var/./www/index.html").unwrap(),
280                PathBuf::from("/var/www/index.html"),
281            );
282            assert_eq!(clean("/a/../b"), Err(UnsafePathError::ParentDir));
283        }
284    }
285
286    #[test]
287    fn rejects_absolute_paths_when_relative_only() {
288        #[cfg(unix)]
289        {
290            assert_eq!(
291                sanitize_relative_path("/etc/passwd"),
292                Err(UnsafePathError::Absolute),
293            );
294            assert_eq!(sanitize_relative_path("/"), Err(UnsafePathError::Absolute),);
295        }
296        assert_eq!(sanitize_relative_path("a/b").unwrap(), PathBuf::from("a/b"));
297        assert_eq!(
298            sanitize_relative_path("../a"),
299            Err(UnsafePathError::ParentDir),
300        );
301    }
302
303    #[test]
304    fn error_converts_to_invalid_input_io_error() {
305        let io_err: std::io::Error = UnsafePathError::ParentDir.into();
306        assert_eq!(io_err.kind(), std::io::ErrorKind::InvalidInput);
307    }
308
309    fn is_reserved(name: &str) -> bool {
310        is_reserved_device_name(name)
311    }
312
313    #[test]
314    fn detects_reserved_dos_names() {
315        for name in [
316            "CON", "con", "Con", "PRN", "AUX", "NUL", "nul", "COM1", "COM9", "com1", "LPT1",
317            "LPT9", "CONIN$", "CONOUT$", "CON.txt", "NUL.log", "com1.dat", "LPT3.bin", "CON  ",
318            "CON.", "CON:", "COM0", "LPT0",
319        ] {
320            assert!(is_reserved(name), "expected `{name}` to be reserved");
321        }
322        assert!(is_reserved("COM\u{00B9}"));
323        assert!(is_reserved("COM\u{00B2}"));
324        assert!(is_reserved("COM\u{00B3}"));
325    }
326
327    #[test]
328    fn allows_non_reserved_names() {
329        for name in [
330            "CONSOLE",
331            "COM",
332            "COM10",
333            "LPT",
334            "LPT10",
335            "NULL",
336            "file.txt",
337            "console.log",
338            "communication",
339            "prnt",
340            "auxiliary",
341        ] {
342            assert!(!is_reserved(name), "expected `{name}` to be allowed");
343        }
344    }
345}