Skip to main content

flymcprust_core/
security.rs

1//! Helpers for handling untrusted client input safely.
2
3use std::path::{Component, Path, PathBuf};
4
5use crate::tool::ToolError;
6
7/// Resolves a client-provided relative path inside `root`, refusing anything
8/// that could escape it.
9///
10/// Use it whenever a resource variable (typically `{+path}`) or a tool
11/// argument names a file:
12///
13/// ```no_run
14/// # use flymcprust_core::{ToolError, security::confine_path};
15/// /// A file of the workspace.
16/// // #[resource(template = "file:///{+path}")]
17/// fn file(path: String) -> Result<String, ToolError> {
18///     let full = confine_path("/srv/workspace", &path)?;
19///     std::fs::read_to_string(full).map_err(|_| ToolError::not_found())
20/// }
21/// ```
22///
23/// Rejected with [`ToolError::not_found`] (which reveals nothing about the
24/// file system):
25///
26/// - absolute paths, drive or UNC prefixes, and `..` components;
27/// - empty paths and paths containing NUL or other control characters;
28/// - paths that leave `root` after following symbolic links (checked on the
29///   canonical path, so the target must exist).
30///
31/// `root` itself is trusted and must exist.
32///
33/// # Errors
34///
35/// Returns [`ToolError::not_found`] when the path is invalid, escapes
36/// `root`, or does not exist.
37pub fn confine_path(root: impl AsRef<Path>, relative: &str) -> Result<PathBuf, ToolError> {
38    if relative.is_empty() || relative.chars().any(char::is_control) {
39        return Err(ToolError::not_found());
40    }
41    // Both separators count on every platform, so a Windows-style `..\`
42    // cannot slip through on Unix and vice versa.
43    let normalized = relative.replace('\\', "/");
44    let mut clean = PathBuf::new();
45    for component in Path::new(&normalized).components() {
46        match component {
47            Component::Normal(part) => clean.push(part),
48            Component::CurDir => {}
49            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
50                return Err(ToolError::not_found());
51            }
52        }
53    }
54    if clean.as_os_str().is_empty() || (cfg!(windows) && normalized.contains(':')) {
55        // `C:` or alternate data streams (`file:stream`) on Windows.
56        return Err(ToolError::not_found());
57    }
58    let root = root
59        .as_ref()
60        .canonicalize()
61        .map_err(|_| ToolError::not_found())?;
62    let resolved = root
63        .join(&clean)
64        .canonicalize()
65        .map_err(|_| ToolError::not_found())?;
66    if resolved.starts_with(&root) {
67        Ok(resolved)
68    } else {
69        Err(ToolError::not_found())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn workspace() -> PathBuf {
78        let dir = std::env::temp_dir().join(format!(
79            "flymcprust-confine-{}-{:?}",
80            std::process::id(),
81            std::thread::current().id()
82        ));
83        std::fs::create_dir_all(dir.join("sub")).unwrap();
84        std::fs::write(dir.join("sub").join("a.txt"), "a").unwrap();
85        dir
86    }
87
88    #[test]
89    fn accepts_paths_inside_the_root() {
90        let root = workspace();
91        let p = confine_path(&root, "sub/a.txt").unwrap();
92        assert!(p.ends_with("a.txt"));
93        assert!(confine_path(&root, "./sub/./a.txt").is_ok());
94        assert!(confine_path(&root, "sub\\a.txt").is_ok());
95    }
96
97    #[test]
98    fn rejects_escapes() {
99        let root = workspace();
100        for bad in [
101            "",
102            "../outside",
103            "sub/../../outside",
104            "..\\outside",
105            "/etc/passwd",
106            "\\\\server\\share",
107            "C:\\Windows\\win.ini",
108            "sub/a.txt\0",
109            "sub/\na.txt",
110            "missing.txt",
111        ] {
112            assert!(
113                confine_path(&root, bad).is_err(),
114                "{bad:?} must be rejected"
115            );
116        }
117    }
118
119    #[cfg(unix)]
120    #[test]
121    fn rejects_symlinks_leaving_the_root() {
122        let root = workspace();
123        let link = root.join("escape");
124        let _ = std::fs::remove_file(&link);
125        std::os::unix::fs::symlink("/", &link).unwrap();
126        assert!(confine_path(&root, "escape/etc").is_err());
127    }
128}