Skip to content

Commit 80cd7b2

Browse files
committed
Fix directory traversal vulnerability in static file serving
This commit fixes a vulnerability where percent-encoded characters like `%2e%2e%2f` could bypass the simple string-based `sanitize_path` protection, potentially leading to Local File Inclusion (LFI). The fix: 1. Adds `percent-encoding` to properly decode requested paths. 2. Uses `tokio::fs::canonicalize()` on both the root directory and the requested file path. 3. Explicitly verifies that the resolved, canonicalized file path starts with the canonicalized root directory. This canonicalization check provides an iron-clad defense against all forms of directory traversal.
1 parent 632680c commit 80cd7b2

4 files changed

Lines changed: 71 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/rustapi-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ repository.workspace = true
1010
homepage.workspace = true
1111

1212
[dependencies]
13+
percent-encoding = "2.3"
1314
# Async
1415
tokio = { workspace = true, features = ["rt", "net", "time", "fs", "macros", "io-util"] }
1516
futures-util = { workspace = true }

crates/rustapi-core/src/static_files.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,13 @@ impl StaticFile {
253253
relative_path: &str,
254254
config: &StaticFileConfig,
255255
) -> Result<Response, ApiError> {
256-
// Sanitize path to prevent directory traversal
257-
let clean_path = sanitize_path(relative_path);
256+
// Percent-decode the path first
257+
let decoded_path = percent_encoding::percent_decode_str(relative_path)
258+
.decode_utf8()
259+
.unwrap_or(std::borrow::Cow::Borrowed(relative_path));
260+
261+
// Sanitize path to prevent basic directory traversal
262+
let clean_path = sanitize_path(&decoded_path);
258263
let file_path = config.root.join(&clean_path);
259264

260265
// Check if it's a directory
@@ -282,6 +287,21 @@ impl StaticFile {
282287

283288
/// Serve a specific file
284289
async fn serve_file(path: &Path, config: &StaticFileConfig) -> Result<Response, ApiError> {
290+
// Security check: ensure the resolved path is within the root directory
291+
let canonical_root = match tokio::fs::canonicalize(&config.root).await {
292+
Ok(root) => root,
293+
Err(_) => return Err(ApiError::internal("Static file root directory not found")),
294+
};
295+
296+
let canonical_file = match tokio::fs::canonicalize(path).await {
297+
Ok(file) => file,
298+
Err(_) => return Err(ApiError::not_found("File not found")),
299+
};
300+
301+
if !canonical_file.starts_with(&canonical_root) {
302+
return Err(ApiError::not_found("File not found"));
303+
}
304+
285305
// Check if file exists
286306
let metadata = fs::metadata(path)
287307
.await
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use rustapi_core::static_files::serve_dir;
2+
use rustapi_core::static_files::StaticFile;
3+
use std::fs::File;
4+
5+
#[tokio::test]
6+
async fn test_directory_traversal_blocked() {
7+
let config = serve_dir("/static", "./"); // root is crates/rustapi-core
8+
9+
// We try to access something outside the root
10+
let relative_path = "../../etc/passwd";
11+
let res = StaticFile::serve(relative_path, &config).await;
12+
assert!(res.is_err(), "Standard traversal should be blocked");
13+
14+
// Percent encoded payload
15+
let relative_path_encoded = "..%2F..%2Fetc%2Fpasswd";
16+
let res_encoded = StaticFile::serve(relative_path_encoded, &config).await;
17+
assert!(res_encoded.is_err(), "Encoded traversal should be blocked");
18+
19+
// Double encoded
20+
let relative_path_double = "%2e%2e%2f%2e%2e%2fetc%2fpasswd";
21+
let res_double = StaticFile::serve(relative_path_double, &config).await;
22+
assert!(res_double.is_err(), "Double encoded traversal should be blocked");
23+
}
24+
25+
#[tokio::test]
26+
async fn test_valid_file_served() {
27+
let config = serve_dir("/static", "./");
28+
29+
// Valid file
30+
let relative_path = "src/lib.rs";
31+
let res = StaticFile::serve(relative_path, &config).await;
32+
assert!(res.is_ok(), "Valid file should be served");
33+
}
34+
35+
#[tokio::test]
36+
async fn test_valid_file_with_spaces_served() {
37+
let _ = std::fs::create_dir_all("./test_dir");
38+
let _ = File::create("./test_dir/file with spaces.txt");
39+
40+
let config = serve_dir("/static", "./test_dir");
41+
42+
let relative_path = "file%20with%20spaces.txt";
43+
let res = StaticFile::serve(relative_path, &config).await;
44+
assert!(res.is_ok(), "File with percent-encoded spaces should be served");
45+
46+
let _ = std::fs::remove_dir_all("./test_dir");
47+
}

0 commit comments

Comments
 (0)