Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions src/handlers/object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ pub async fn get_object(
None => return S3ErrorType::NoSuchBucket.to_response(Some(bucket)),
};

let path = storage.join(key.trim_start_matches('/'));
let path = match safe_join(storage, &key) {
Some(p) => p,
None => return S3ErrorType::AccessDenied.to_response(Some(key)),
};
let mut file = match File::open(&path).await {
Ok(f) => f,
Err(_) => return S3ErrorType::NoSuchKey.to_response(Some(key)),
Expand Down Expand Up @@ -161,7 +164,10 @@ pub async fn head_object(
None => return S3ErrorType::NoSuchBucket.to_response(Some(bucket)),
};

let path = storage.join(key.trim_start_matches('/'));
let path = match safe_join(storage, &key) {
Some(p) => p,
None => return S3ErrorType::AccessDenied.to_response(Some(key)),
};
let metadata = match fs::metadata(&path).await {
Ok(m) => m,
Err(_) => return S3ErrorType::NoSuchKey.to_response(Some(key)),
Expand Down Expand Up @@ -205,7 +211,10 @@ pub async fn put_object(
None => return S3ErrorType::NoSuchBucket.to_response(Some(bucket)),
};

let path = storage.join(key.trim_start_matches('/'));
let path = match safe_join(storage, &key) {
Some(p) => p,
None => return S3ErrorType::AccessDenied.to_response(Some(key)),
};

if let Some(copy_source) = headers
.get("x-amz-copy-source")
Expand Down Expand Up @@ -261,7 +270,10 @@ async fn copy_object(
None => return S3ErrorType::NoSuchBucket.to_response(Some(source_bucket)),
};

let source_path = source_storage.join(source_key.trim_start_matches('/'));
let source_path = match safe_join(source_storage, &source_key) {
Some(p) => p,
None => return S3ErrorType::AccessDenied.to_response(Some(source_key)),
};
let source_metadata = match fs::metadata(&source_path).await {
Ok(metadata) if !metadata.is_dir() => metadata,
Ok(_) => return S3ErrorType::NoSuchKey.to_response(Some(source_key)),
Expand Down Expand Up @@ -318,7 +330,10 @@ pub async fn delete_object(
None => return S3ErrorType::NoSuchBucket.to_response(Some(bucket)),
};

let path = storage.join(key.trim_start_matches('/'));
let path = match safe_join(storage, &key) {
Some(p) => p,
None => return S3ErrorType::AccessDenied.to_response(Some(key)),
};
if let Err(_) = fs::remove_file(&path).await {
// S3 returns 204 even if file doesn't exist during DELETE
return StatusCode::NO_CONTENT.into_response();
Expand Down Expand Up @@ -391,3 +406,44 @@ fn parse_copy_source(copy_source: &str) -> Option<(String, String)> {
fn owner_id() -> String {
"75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a".to_string()
}

fn safe_join(storage: &std::path::Path, key: &str) -> Option<std::path::PathBuf> {
use std::path::Component;
let mut resolved = storage.to_path_buf();
for component in std::path::Path::new(key).components() {
match component {
Component::Normal(c) => resolved.push(c),
Component::RootDir | Component::CurDir => continue,
Component::Prefix(_) | Component::ParentDir => return None,
}
}
Some(resolved)
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;

#[test]
fn test_safe_join() {
let storage = Path::new("/var/data");

// Normal keys
assert_eq!(safe_join(storage, "my_file.txt").unwrap(), Path::new("/var/data/my_file.txt"));
assert_eq!(safe_join(storage, "folder/file.txt").unwrap(), Path::new("/var/data/folder/file.txt"));

// Leading slashes are ignored (RootDir)
assert_eq!(safe_join(storage, "/folder/file.txt").unwrap(), Path::new("/var/data/folder/file.txt"));

// Current dir dots are ignored
assert_eq!(safe_join(storage, "./folder/./file.txt").unwrap(), Path::new("/var/data/folder/file.txt"));

// ParentDir traversal is rejected
assert!(safe_join(storage, "../etc/passwd").is_none());
assert!(safe_join(storage, "folder/../../etc/passwd").is_none());

// Windows prefixes are rejected
assert!(safe_join(storage, "C:/Windows/System32").is_none());
}
}