Skip to main content

fastskill_core/security/
path.rs

1//! Path security utilities to prevent directory traversal attacks
2
3use std::path::{Component, Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum PathSecurityError {
8    #[error("Path traversal attempt detected: {0}")]
9    TraversalAttempt(String),
10
11    #[error("Invalid path component: {0}")]
12    InvalidComponent(String),
13
14    #[error("Path canonicalization failed: {0}")]
15    CanonicalizationFailed(String),
16
17    #[error("Path escapes root directory: {0}")]
18    EscapesRoot(String),
19}
20
21/// Sanitize a path component by removing or replacing dangerous characters
22/// Allows only alphanumeric characters, hyphens, underscores, and dots
23pub fn sanitize_path_component(component: &str) -> String {
24    component
25        .chars()
26        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
27        .collect()
28}
29
30/// Normalize a path by resolving . and .. components in memory
31/// This function does not access the filesystem and works on non-existent paths
32pub fn normalize_path(path: &Path) -> PathBuf {
33    let mut result = PathBuf::new();
34    for component in path.components() {
35        match component {
36            Component::Prefix(_) | Component::RootDir => {
37                result.push(component);
38            }
39            Component::CurDir => {
40                // Skip current directory components
41            }
42            Component::ParentDir => {
43                // Pop from result if possible, otherwise we've escaped root
44                if !result.pop() {
45                    // Path tries to escape, return empty to indicate invalid
46                    return PathBuf::new();
47                }
48            }
49            Component::Normal(s) => {
50                result.push(s);
51            }
52        }
53    }
54    result
55}
56
57/// Validate that a path stays within the allowed root directory
58/// This prevents directory traversal attacks
59pub fn validate_path_within_root(path: &Path, root: &Path) -> Result<PathBuf, PathSecurityError> {
60    // Resolve both paths to absolute paths
61    let abs_root = root.canonicalize().map_err(|e| {
62        PathSecurityError::CanonicalizationFailed(format!("Failed to canonicalize root: {}", e))
63    })?;
64
65    // If the path doesn't exist yet, we need to check its parent
66    let abs_path = if path.exists() {
67        path.canonicalize().map_err(|e| {
68            PathSecurityError::CanonicalizationFailed(format!("Failed to canonicalize path: {}", e))
69        })?
70    } else {
71        // For non-existent paths, canonicalize the parent and append the filename
72        let parent = path.parent().unwrap_or(Path::new("."));
73        let abs_parent = if parent.as_os_str().is_empty() {
74            std::env::current_dir().map_err(|e| {
75                PathSecurityError::CanonicalizationFailed(format!(
76                    "Failed to get current directory: {}",
77                    e
78                ))
79            })?
80        } else if parent.exists() {
81            parent.canonicalize().map_err(|e| {
82                PathSecurityError::CanonicalizationFailed(format!(
83                    "Failed to canonicalize parent: {}",
84                    e
85                ))
86            })?
87        } else {
88            // If parent doesn't exist, we need to validate the constructed path
89            // Normalize the path to resolve any .. or . components
90            let relative_to_root = path.strip_prefix(root).unwrap_or(path);
91            let normalized_relative = normalize_path(relative_to_root);
92            if normalized_relative.as_os_str().is_empty() {
93                // Path escapes root through ..
94                return Err(PathSecurityError::EscapesRoot(format!(
95                    "Path '{}' attempts to escape root directory '{}'",
96                    path.display(),
97                    abs_root.display()
98                )));
99            }
100            let normalized_path = abs_root.join(&normalized_relative);
101            // Verify the normalized path is within root
102            if !normalized_path.starts_with(&abs_root) {
103                return Err(PathSecurityError::EscapesRoot(format!(
104                    "Path '{}' attempts to escape root directory '{}'",
105                    path.display(),
106                    abs_root.display()
107                )));
108            }
109            return Ok(normalized_path);
110        };
111
112        if let Some(filename) = path.file_name() {
113            abs_parent.join(filename)
114        } else {
115            abs_parent
116        }
117    };
118
119    // Check if the resolved path is within the root
120    if !abs_path.starts_with(&abs_root) {
121        return Err(PathSecurityError::EscapesRoot(format!(
122            "Path '{}' attempts to escape root directory '{}'",
123            abs_path.display(),
124            abs_root.display()
125        )));
126    }
127
128    Ok(abs_path)
129}
130
131/// Validate and sanitize a user-provided path component
132/// Returns an error if the component contains path traversal attempts
133pub fn validate_path_component(component: &str) -> Result<String, PathSecurityError> {
134    // Check for obvious traversal attempts
135    if component.contains("..") || component.contains('/') || component.contains('\\') {
136        return Err(PathSecurityError::TraversalAttempt(format!(
137            "Path component '{}' contains directory traversal characters",
138            component
139        )));
140    }
141
142    // Check for absolute paths
143    if component.starts_with('/') || (cfg!(windows) && component.contains(':')) {
144        return Err(PathSecurityError::InvalidComponent(format!(
145            "Path component '{}' appears to be an absolute path",
146            component
147        )));
148    }
149
150    Ok(component.to_string())
151}
152
153/// Safely join a user-provided path to a root directory
154/// Validates that the result stays within the root
155pub fn safe_join(root: &Path, user_path: &str) -> Result<PathBuf, PathSecurityError> {
156    // First validate each component
157    let components: Vec<&str> = user_path.split('/').collect();
158    for component in components {
159        validate_path_component(component)?;
160    }
161
162    // Join and validate the final path
163    let joined = root.join(user_path);
164
165    // For paths that don't exist yet, just verify components don't have traversal
166    // The actual existence will be checked by the calling code
167    if !joined.exists() {
168        return Ok(joined);
169    }
170
171    validate_path_within_root(&joined, root)
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
176mod tests {
177    use super::*;
178    use std::fs;
179    use tempfile::TempDir;
180
181    #[test]
182    fn test_sanitize_path_component() {
183        assert_eq!(sanitize_path_component("valid-name_123"), "valid-name_123");
184        assert_eq!(sanitize_path_component("../etc/passwd"), "..etcpasswd");
185        assert_eq!(sanitize_path_component("../../"), "...."); // dots are allowed (for file extensions)
186        assert_eq!(
187            sanitize_path_component("file with spaces"),
188            "filewithspaces"
189        );
190    }
191
192    #[test]
193    fn test_validate_path_component() {
194        assert!(validate_path_component("valid-name").is_ok());
195        assert!(validate_path_component("valid_name_123").is_ok());
196        assert!(validate_path_component("..").is_err());
197        assert!(validate_path_component("../etc").is_err());
198        assert!(validate_path_component("/etc/passwd").is_err());
199        assert!(validate_path_component("path/to/file").is_err());
200    }
201
202    #[test]
203    fn test_validate_path_within_root() {
204        let temp_dir = TempDir::new().unwrap();
205        let root = temp_dir.path();
206
207        // Create a test file within root
208        let valid_file = root.join("valid.txt");
209        fs::write(&valid_file, "test").unwrap();
210
211        // Test valid path
212        assert!(validate_path_within_root(&valid_file, root).is_ok());
213
214        // Test path outside root
215        let outside_path = temp_dir.path().parent().unwrap().join("outside.txt");
216        if outside_path.exists() {
217            assert!(validate_path_within_root(&outside_path, root).is_err());
218        }
219    }
220
221    #[test]
222    fn test_safe_join() {
223        let temp_dir = TempDir::new().unwrap();
224        let root = temp_dir.path();
225
226        // Valid join
227        assert!(safe_join(root, "subdir/file.txt").is_ok());
228
229        // Invalid traversal
230        assert!(safe_join(root, "../etc/passwd").is_err());
231        assert!(safe_join(root, "subdir/../../etc").is_err());
232    }
233
234    #[test]
235    fn test_validated_return_value_is_safe() {
236        let temp_dir = TempDir::new().unwrap();
237        let root = temp_dir.path();
238
239        // Validate path component and get the safe return value
240        let safe_component = validate_path_component("valid-name").unwrap();
241
242        // Build path using the validated return value
243        let path = root.join(&safe_component);
244
245        // Verify the path is safe and doesn't escape root
246        let canonical_path = path.canonicalize().unwrap_or(path);
247        let canonical_root = root.canonicalize().unwrap_or(root.to_path_buf());
248        assert!(canonical_path.starts_with(&canonical_root));
249
250        // Verify the validated string doesn't contain dangerous characters
251        assert!(!safe_component.contains(".."));
252        assert!(!safe_component.contains('/'));
253        assert!(!safe_component.contains('\\'));
254    }
255
256    #[test]
257    fn test_validate_path_within_root_nonexistent_traversal_rejected() {
258        let temp_dir = TempDir::new().unwrap();
259        let root = temp_dir.path();
260
261        // Test path that does not exist and would escape when resolved
262        let escape_path = root.join("subdir/../../escape");
263        let result = validate_path_within_root(&escape_path, root);
264
265        assert!(matches!(result, Err(PathSecurityError::EscapesRoot(_))));
266    }
267}