use anyhow::{bail, Result};
pub const MAX_PATH_LENGTH: usize = 1024;
pub const MAX_PATH_COMPONENTS: usize = 32;
pub fn validate_request_path(path: &str) -> Result<String> {
if path.len() > MAX_PATH_LENGTH {
bail!("Path too long");
}
if !path.starts_with('/') {
bail!("Path must start with /");
}
let decoded = match urlencoding::decode(path) {
Ok(decoded) => decoded.into_owned(),
Err(_) => bail!("Invalid URL encoding"),
};
if decoded.contains('\0') {
bail!("Path contains null bytes");
}
let components: Vec<&str> = decoded.split('/').skip(1).collect();
if components.len() > MAX_PATH_COMPONENTS {
bail!("Too many path components");
}
let mut sanitized_components = Vec::new();
for component in components {
if component.is_empty() {
continue;
}
if component == ".." || component == "." {
bail!("Path traversal attempt detected");
}
if component.contains(['\\', '\0', '<', '>', '|', '?', '*']) {
bail!("Invalid characters in path component");
}
if component.starts_with('.') && component != ".well-known" {
bail!("Access to hidden files denied");
}
if component.len() > 255 {
bail!("Path component too long");
}
sanitized_components.push(component);
}
let safe_path = if sanitized_components.is_empty() {
"/".to_string()
} else {
format!("/{}", sanitized_components.join("/"))
};
Ok(safe_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_validation() {
assert!(validate_request_path("/").is_ok());
assert!(validate_request_path("/index.html").is_ok());
assert!(validate_request_path("/assets/style.css").is_ok());
assert!(validate_request_path("/.well-known/acme-challenge/token").is_ok());
assert!(validate_request_path("/.well-known/security.txt").is_ok());
assert!(validate_request_path("../etc/passwd").is_err());
assert!(validate_request_path("/.env").is_err());
assert!(validate_request_path("/.secret").is_err());
assert!(validate_request_path("/path/with/../../traversal").is_err());
assert!(validate_request_path("/path\0null").is_err());
}
}