Skip to main content

nano_web/
path.rs

1use anyhow::{bail, Result};
2
3/// Maximum path length to prevent buffer overflows
4pub const MAX_PATH_LENGTH: usize = 1024;
5
6/// Maximum number of path components to prevent deeply nested attacks
7pub const MAX_PATH_COMPONENTS: usize = 32;
8
9/// Validate and sanitize HTTP request path to prevent directory traversal attacks
10pub fn validate_request_path(path: &str) -> Result<String> {
11    // Basic length check
12    if path.len() > MAX_PATH_LENGTH {
13        bail!("Path too long");
14    }
15
16    // Must start with /
17    if !path.starts_with('/') {
18        bail!("Path must start with /");
19    }
20
21    // Decode URL encoding safely
22    let decoded = match urlencoding::decode(path) {
23        Ok(decoded) => decoded.into_owned(),
24        Err(_) => bail!("Invalid URL encoding"),
25    };
26
27    // Check for null bytes (security)
28    if decoded.contains('\0') {
29        bail!("Path contains null bytes");
30    }
31
32    // Split into components and validate each
33    let components: Vec<&str> = decoded.split('/').skip(1).collect(); // Skip first empty component
34
35    if components.len() > MAX_PATH_COMPONENTS {
36        bail!("Too many path components");
37    }
38
39    let mut sanitized_components = Vec::new();
40
41    for component in components {
42        // Skip empty components (double slashes)
43        if component.is_empty() {
44            continue;
45        }
46
47        // Reject dangerous components
48        if component == ".." || component == "." {
49            bail!("Path traversal attempt detected");
50        }
51
52        // Check for dangerous characters
53        if component.contains(['\\', '\0', '<', '>', '|', '?', '*']) {
54            bail!("Invalid characters in path component");
55        }
56
57        // Reject hidden files/directories (starting with .) except .well-known
58        if component.starts_with('.') && component != ".well-known" {
59            bail!("Access to hidden files denied");
60        }
61
62        // Reject overly long components
63        if component.len() > 255 {
64            bail!("Path component too long");
65        }
66
67        sanitized_components.push(component);
68    }
69
70    // Reconstruct safe path
71    let safe_path = if sanitized_components.is_empty() {
72        "/".to_string()
73    } else {
74        format!("/{}", sanitized_components.join("/"))
75    };
76
77    Ok(safe_path)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn test_path_validation() {
86        // Valid paths
87        assert!(validate_request_path("/").is_ok());
88        assert!(validate_request_path("/index.html").is_ok());
89        assert!(validate_request_path("/assets/style.css").is_ok());
90        assert!(validate_request_path("/.well-known/acme-challenge/token").is_ok());
91        assert!(validate_request_path("/.well-known/security.txt").is_ok());
92
93        // Invalid paths
94        assert!(validate_request_path("../etc/passwd").is_err());
95        assert!(validate_request_path("/.env").is_err());
96        assert!(validate_request_path("/.secret").is_err());
97        assert!(validate_request_path("/path/with/../../traversal").is_err());
98        assert!(validate_request_path("/path\0null").is_err());
99    }
100}