#![cfg(test)]
use crate::web::response::StatusCode;
use crate::web::static_files::StaticFiles;
use std::fs;
use tempfile::TempDir;
trait SyncHandlerExt {
fn call_sync(&self, req: crate::web::extract::Request) -> crate::web::response::Response;
}
impl SyncHandlerExt for crate::web::static_files::StaticFilesHandler {
fn call_sync(&self, req: crate::web::extract::Request) -> crate::web::response::Response {
futures_lite::future::block_on(crate::web::handler::Handler::call(
self,
&crate::Cx::for_testing(),
req,
))
}
}
fn body_contains_bytes(body: &[u8], needle: &[u8]) -> bool {
!needle.is_empty() && body.windows(needle.len()).any(|window| window == needle)
}
#[test]
fn audit_body_contains_bytes_matches_subslices_not_single_bytes() {
let body = b"safe payload with ERROR: traversal detected marker";
assert!(
body_contains_bytes(body, b"ERROR: traversal detected"),
"byte body matcher must find multi-byte diagnostic markers"
);
assert!(
!body_contains_bytes(body, b"ERROR: different marker"),
"byte body matcher must not collapse to single-byte contains semantics"
);
assert!(
!body_contains_bytes(body, b""),
"empty marker is not a useful audit match"
);
}
#[test]
fn audit_basic_path_traversal_rejected() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let traversal_paths = vec![
"/static/../etc/passwd", "/static/../../etc/passwd", "/static/../../../etc/passwd", "/files/../config/secrets.txt", "/assets/../../../bin/sh", "/public/../.env", "/static/..\\windows\\system32", ];
for path in traversal_paths {
let request = crate::web::extract::Request::new("GET", path);
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"Path traversal attempt '{path}' must be rejected with 404, not served"
);
assert!(
response.body.is_empty() || !response.body.as_ref().starts_with(b"root:"),
"Path traversal attempt '{path}' must not leak passwd file content"
);
}
}
#[test]
fn audit_url_encoded_path_traversal_rejected() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let encoded_traversal_paths = vec![
"/static/%2e%2e/etc/passwd", "/static%2f..%2fetc%2fpasswd", "/static%5c..%5cetc%5cpasswd", "/static/%2e%2e%2f%2e%2e/etc/passwd", ];
for path in encoded_traversal_paths {
let request = crate::web::extract::Request::new("GET", path);
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"URL-encoded path traversal '{path}' must be decoded and rejected with 404"
);
}
}
#[test]
fn audit_double_encoded_path_traversal_rejected() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let double_encoded_paths = vec![
"/static/%252e%252e/etc/passwd", "/static%252f..%252fetc%252fpasswd", "/static%255c..%255cetc%255cpasswd", "/%252e%252e%252f%252e%252e/etc/passwd", ];
for path in double_encoded_paths {
let request = crate::web::extract::Request::new("GET", path);
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"Double-encoded path traversal '{path}' must be decoded and rejected with 404"
);
}
}
#[test]
fn audit_unicode_dot_path_traversal_rejected() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let unicode_dot_paths = vec![
"/static/\u{2024}\u{2024}/etc/passwd", "/static/\u{FE52}\u{FE52}/etc/passwd", "/static/\u{FF0E}\u{FF0E}/etc/passwd", "/static/.\u{2024}/etc/passwd", ];
for path in unicode_dot_paths {
let request = crate::web::extract::Request::new("GET", path);
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"Unicode dot traversal '{path}' must be rejected with 404"
);
}
}
#[test]
fn audit_null_byte_injection_rejected() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let null_byte_paths = vec![
"/static/file\0.txt",
"/static/../../etc/passwd\0.jpg",
"/static/..\0/etc/passwd",
];
for path in null_byte_paths {
let request = crate::web::extract::Request::new("GET", path);
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"Null byte injection '{path:?}' must be rejected with 404"
);
}
}
#[test]
fn audit_legitimate_files_still_accessible() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let legitimate_paths = vec![
"/safe.txt",
"/static/app.js",
"/static/styles.css",
"/assets/image.png",
"/version.1.2/file.txt", "/static/v1.0/api.json", ];
for path in legitimate_paths {
let request = crate::web::extract::Request::new("GET", path);
let response = handler.call_sync(request);
assert!(
response.status == StatusCode::OK || response.status == StatusCode::NOT_FOUND,
"Legitimate path '{path}' must not be security-rejected (got {:?})",
response.status
);
if response.status == StatusCode::OK {
let traversal_marker = b"ERROR: traversal detected";
assert!(
!body_contains_bytes(response.body.as_ref(), traversal_marker),
"Legitimate path '{path}' must serve actual file content, not error message"
);
}
}
}
#[test]
fn audit_path_traversal_rejected_across_http_methods() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let traversal_path = "/static/../etc/passwd";
let methods = vec!["GET", "HEAD", "POST", "PUT", "DELETE"];
for method in methods {
let request = crate::web::extract::Request::new(method, traversal_path);
let response = handler.call_sync(request);
assert_ne!(
response.status,
StatusCode::OK,
"Path traversal via {method} '{traversal_path}' must not succeed"
);
assert!(
!response.body.as_ref().starts_with(b"root:"),
"Path traversal via {method} must not leak passwd content"
);
}
}
#[cfg(unix)]
#[test]
fn audit_symlink_traversal_blocked() {
let dir = setup_secure_test_environment();
let outside_dir = TempDir::new().unwrap();
let secret_file = outside_dir.path().join("secret.txt");
fs::write(&secret_file, "top secret data").unwrap();
let symlink_path = dir.path().join("evil_link");
std::os::unix::fs::symlink(&secret_file, &symlink_path).unwrap();
let handler = StaticFiles::new(dir.path()).handler();
let request = crate::web::extract::Request::new("GET", "/evil_link");
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"Symlink pointing outside document root must be rejected"
);
assert_ne!(
response.body.as_ref(),
b"top secret data",
"Symlink traversal must not leak external file content"
);
}
#[test]
fn audit_comprehensive_traversal_attack_simulation() {
let dir = setup_secure_test_environment();
let handler = StaticFiles::new(dir.path()).handler();
let attack_sequence = vec![
("/../etc/passwd", "Basic traversal"),
("/static/../../../etc/passwd", "Deep traversal"),
("/static/%2e%2e/etc/passwd", "URL encoded"),
("/static%2f..%2fetc%2fpasswd", "Path separator encoded"),
("/static/%252e%252e/etc/passwd", "Double encoded"),
("/static/\u{2024}\u{2024}/etc/passwd", "Unicode dots"),
("/static/../../etc/passwd\0.txt", "Null byte suffix"),
("/static/../../../proc/version", "Proc filesystem"),
("/static/../../../home/user/.ssh/id_rsa", "SSH keys"),
("/static/../../../var/log/auth.log", "Log files"),
];
for (attack_path, attack_name) in attack_sequence {
let request = crate::web::extract::Request::new("GET", attack_path);
let response = handler.call_sync(request);
assert_eq!(
response.status,
StatusCode::NOT_FOUND,
"Attack '{attack_name}' using path '{attack_path}' must be rejected"
);
let body_str = String::from_utf8_lossy(&response.body);
let sensitive_patterns = vec![
"root:",
"Linux version",
"ssh-rsa",
"BEGIN PRIVATE KEY",
"password",
"secret",
"auth",
"login",
"/bin/bash",
];
for pattern in &sensitive_patterns {
assert!(
!body_str.to_lowercase().contains(&pattern.to_lowercase()),
"Attack '{attack_name}' must not leak content matching '{pattern}'"
);
}
}
}
fn setup_secure_test_environment() -> TempDir {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("safe.txt"), "This is a safe file").unwrap();
let static_dir = dir.path().join("static");
fs::create_dir(&static_dir).unwrap();
fs::write(static_dir.join("app.js"), "console.log('app');").unwrap();
fs::write(static_dir.join("styles.css"), "body { margin: 0; }").unwrap();
let assets_dir = dir.path().join("assets");
fs::create_dir(&assets_dir).unwrap();
fs::write(assets_dir.join("image.png"), "test PNG fixture data").unwrap();
let version_dir = dir.path().join("version.1.2");
fs::create_dir(&version_dir).unwrap();
fs::write(version_dir.join("file.txt"), "version file").unwrap();
let v1_dir = static_dir.join("v1.0");
fs::create_dir(&v1_dir).unwrap();
fs::write(v1_dir.join("api.json"), r#"{"api":"v1"}"#).unwrap();
dir
}