#![cfg(feature = "server")]
mod common;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::middleware::from_fn_with_state;
use axum::Router;
use chrono::Utc;
use reqwest::StatusCode;
use tempfile::TempDir;
use tokio::net::TcpListener;
use rs3gw::api::auth_middleware::{detect_auth_mode, sigv4_auth_layer, AuthMode};
use rs3gw::api::s3_router;
use rs3gw::auth::v4::PresignedUrlGenerator;
struct AuthTestServer {
pub base_url: String,
_handle: tokio::task::JoinHandle<()>,
_temp_dir: TempDir,
}
async fn setup_auth_server(access_key: &str, secret_key: &str) -> AuthTestServer {
common::init_tracing();
let temp_dir = TempDir::new().expect("temp dir");
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind listener");
let addr: SocketAddr = listener.local_addr().expect("local addr");
let storage_root = temp_dir.path().to_path_buf();
let storage =
Arc::new(rs3gw::storage::StorageEngine::new(storage_root.clone()).expect("storage engine"));
let metrics_handle = rs3gw::metrics::init_metrics().expect("metrics");
let config = rs3gw::Config {
bind_addr: addr,
storage_root: storage_root.clone(),
default_bucket: "default".to_string(),
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
compression: rs3gw::storage::CompressionMode::None,
request_timeout_secs: 0,
max_concurrent_requests: 0,
tls: rs3gw::TlsConfig::default(),
connection_pool: rs3gw::ConnectionPoolConfig::default(),
cluster: rs3gw::cluster::ClusterConfig::default(),
dedup: rs3gw::storage::DedupConfig::disabled(),
zerocopy: rs3gw::storage::ZeroCopyConfig::default(),
select_cache: rs3gw::SelectCacheConfig::default(),
multipart_retention_hours: 168,
fsync: false,
};
let preprocessing_path = storage_root.join("preprocessing");
let preprocessing_manager = Arc::new(rs3gw::storage::preprocessing::PreprocessingManager::new(
preprocessing_path,
));
let predictive_analytics = Arc::new(rs3gw::observability::PredictiveAnalytics::new(
10_000,
0.023,
0.09,
0.0004,
1_000_000_000_000,
));
let metrics_tracker = Arc::new(rs3gw::observability::MetricsTracker::new());
let select_result_cache = Arc::new(rs3gw::api::SelectResultCache::new(100, 10 * 1024 * 1024));
#[cfg(feature = "formats")]
let query_intelligence = Arc::new(rs3gw::api::QueryIntelligence::new());
let training_path = storage_root.join("training");
let training_manager = Arc::new(rs3gw::storage::TrainingManager::new(training_path));
let verifier = if !access_key.is_empty() && !secret_key.is_empty() {
Some(Arc::new(rs3gw::auth::v4::SigV4Verifier::new(
access_key.to_string(),
secret_key.to_string(),
"us-east-1".to_string(),
)))
} else {
None
};
let state = rs3gw::AppState {
config,
storage,
metrics_handle,
cache: None,
throttle: None,
quota: None,
event_broadcaster: rs3gw::api::EventBroadcaster::new(),
#[cfg(feature = "formats")]
query_plan_cache: None,
select_result_cache,
#[cfg(feature = "formats")]
query_intelligence,
advanced_replication: None,
preprocessing_manager,
predictive_analytics,
metrics_tracker,
usage_tracker: std::sync::Arc::new(rs3gw::observability::UsageTracker::new()),
training_manager,
start_time: std::time::Instant::now(),
verifier,
auth_failure_counts: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashMap::new(),
)),
in_flight: rs3gw::InFlightTracker::new(),
encryption: std::sync::Arc::new(rs3gw::storage::encryption::EncryptionService::new(
std::sync::Arc::new(rs3gw::storage::encryption::LocalKeyProvider::default()),
)),
};
let app = Router::new()
.merge(s3_router::routes())
.layer(from_fn_with_state(state.clone(), sigv4_auth_layer))
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
tokio::time::sleep(Duration::from_millis(100)).await;
AuthTestServer {
base_url: format!("http://{}", addr),
_handle: handle,
_temp_dir: temp_dir,
}
}
#[tokio::test]
async fn test_no_creds_passthrough_allows_list_buckets() {
let server = setup_auth_server("", "").await;
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::OK,
"Expected 200 in passthrough mode, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_exempt_path_health_always_passes() {
let server = setup_auth_server("mykey", "mysecret").await;
let resp = reqwest::Client::new()
.get(format!("{}/health", server.base_url))
.send()
.await
.expect("HTTP request");
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"/health must never return 403"
);
}
#[tokio::test]
async fn test_with_creds_missing_auth_returns_403() {
let server = setup_auth_server("mykey", "mysecret").await;
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 when auth is required but not provided"
);
}
#[tokio::test]
async fn test_with_creds_wrong_signature_returns_403() {
let server = setup_auth_server("mykey", "mysecret").await;
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header(
"Authorization",
"AWS4-HMAC-SHA256 Credential=WRONGKEY/20240101/us-east-1/s3/aws4_request, \
SignedHeaders=host, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
)
.header("x-amz-date", "20240101T000000Z")
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for wrong SigV4 signature"
);
}
#[tokio::test]
async fn test_with_creds_bad_presigned_returns_403() {
let server = setup_auth_server("mykey", "mysecret").await;
let query = "X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential=WRONGKEY%2F20240101%2Fus-east-1%2Fs3%2Faws4_request\
&X-Amz-Date=20240101T000000Z\
&X-Amz-Expires=86400\
&X-Amz-SignedHeaders=host\
&X-Amz-Signature=0000000000000000000000000000000000000000000000000000000000000000";
let url = format!("{}/mybucket/myobject?{}", server.base_url, query);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for bad presigned URL"
);
}
#[tokio::test]
async fn test_sigv4_timestamp_skew_rejected() {
let server = setup_auth_server("mykey", "mysecret").await;
let future_time = Utc::now() + chrono::Duration::minutes(20);
let timestamp = future_time.format("%Y%m%dT%H%M%SZ").to_string();
let date_str = future_time.format("%Y%m%d").to_string();
let auth = format!(
"AWS4-HMAC-SHA256 Credential=mykey/{date}/us-east-1/s3/aws4_request, \
SignedHeaders=host;x-amz-date, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
date = date_str,
);
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header("Authorization", &auth)
.header("x-amz-date", ×tamp)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for timestamp 20 minutes in the future, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_sigv4_timestamp_skew_past_rejected() {
let server = setup_auth_server("mykey", "mysecret").await;
let past_time = Utc::now() - chrono::Duration::minutes(20);
let timestamp = past_time.format("%Y%m%dT%H%M%SZ").to_string();
let date_str = past_time.format("%Y%m%d").to_string();
let auth = format!(
"AWS4-HMAC-SHA256 Credential=mykey/{date}/us-east-1/s3/aws4_request, \
SignedHeaders=host;x-amz-date, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
date = date_str,
);
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header("Authorization", &auth)
.header("x-amz-date", ×tamp)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for timestamp 20 minutes in the past, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_sigv4_missing_date_header() {
let server = setup_auth_server("mykey", "mysecret").await;
let old_date = (Utc::now() - chrono::Duration::days(30))
.format("%Y%m%d")
.to_string();
let auth = format!(
"AWS4-HMAC-SHA256 Credential=mykey/{date}/us-east-1/s3/aws4_request, \
SignedHeaders=host, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
date = old_date,
);
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header("Authorization", &auth)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 when x-amz-date is missing and credential date is stale, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_sigv4_wrong_region() {
let server = setup_auth_server("mykey", "mysecret").await;
let now = Utc::now();
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_str = now.format("%Y%m%d").to_string();
let auth = format!(
"AWS4-HMAC-SHA256 Credential=mykey/{date}/us-west-2/s3/aws4_request, \
SignedHeaders=host;x-amz-date, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
date = date_str,
);
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header("Authorization", &auth)
.header("x-amz-date", ×tamp)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for wrong region in credential scope, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_sigv4_wrong_service() {
let server = setup_auth_server("mykey", "mysecret").await;
let now = Utc::now();
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_str = now.format("%Y%m%d").to_string();
let auth = format!(
"AWS4-HMAC-SHA256 Credential=mykey/{date}/us-east-1/ec2/aws4_request, \
SignedHeaders=host;x-amz-date, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
date = date_str,
);
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header("Authorization", &auth)
.header("x-amz-date", ×tamp)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for wrong service in credential scope, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_sigv4_unsigned_payload_accepted_path() {
let server = setup_auth_server("mykey", "mysecret").await;
let now = Utc::now();
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_str = now.format("%Y%m%d").to_string();
let auth = format!(
"AWS4-HMAC-SHA256 Credential=mykey/{date}/us-east-1/s3/aws4_request, \
SignedHeaders=host;x-amz-content-sha256;x-amz-date, \
Signature=0000000000000000000000000000000000000000000000000000000000000000",
date = date_str,
);
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.header("Authorization", &auth)
.header("x-amz-date", ×tamp)
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 AccessDenied (signature mismatch), not a payload-hash rejection, got {}",
resp.status()
);
let body = resp.text().await.expect("read body");
assert!(
body.contains("AccessDenied"),
"Response body should contain AccessDenied, got: {}",
body
);
}
#[tokio::test]
async fn test_auth_error_no_secret_leakage() {
let access_key = "AKIAIOSFODNN7EXAMPLE";
let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYSECRET";
let server = setup_auth_server(access_key, secret_key).await;
let resp = reqwest::Client::new()
.get(format!("{}/", server.base_url))
.send()
.await
.expect("HTTP request");
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "Expected 403");
let body = resp.text().await.expect("read body");
assert!(
!body.contains(access_key),
"Response body must not contain the access key, got: {}",
body
);
assert!(
!body.contains("wJalrXUt"),
"Response body must not contain any fragment of the secret key, got: {}",
body
);
assert!(
!body.contains("SECRET"),
"Response body must not contain any fragment of the secret key, got: {}",
body
);
assert!(
body.contains("AccessDenied"),
"Response body should contain AccessDenied, got: {}",
body
);
}
#[tokio::test]
async fn test_auth_rate_limiting_returns_429() {
let server = setup_auth_server("mykey", "mysecret").await;
let client = reqwest::Client::new();
let mut last_status = StatusCode::FORBIDDEN;
for i in 0..=10u32 {
let resp = client
.get(format!("{}/", server.base_url))
.send()
.await
.expect("HTTP request");
last_status = resp.status();
if last_status == StatusCode::TOO_MANY_REQUESTS {
return;
}
if i < 10 {
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
assert_eq!(
last_status,
StatusCode::TOO_MANY_REQUESTS,
"Expected 429 after exceeding auth failure rate limit"
);
}
async fn setup_presigned_server() -> AuthTestServer {
setup_auth_server("testkey", "testsecret").await
}
#[tokio::test]
async fn test_presigned_get_valid() {
let server = setup_presigned_server().await;
let client = reqwest::Client::new();
let host = server.base_url.trim_start_matches("http://").to_string();
let generator = PresignedUrlGenerator::new(
"testkey".to_string(),
"testsecret".to_string(),
"us-east-1".to_string(),
host.clone(),
);
let put_url = generator.generate_presigned_put_url(
"presigned-bucket",
"hello.txt",
3600,
Some("text/plain"),
);
let put_resp = client
.put(&put_url)
.header("host", &host)
.body("hello presigned world")
.send()
.await
.expect("PUT request");
assert_ne!(
put_resp.status(),
StatusCode::FORBIDDEN,
"Presigned PUT must not return 403 (auth), got {}",
put_resp.status()
);
let get_url = generator.generate_presigned_get_url("presigned-bucket", "hello.txt", 3600);
let get_resp = client
.get(&get_url)
.header("host", &host)
.send()
.await
.expect("GET request");
assert_ne!(
get_resp.status(),
StatusCode::FORBIDDEN,
"Presigned GET must not return 403 (auth), got {}",
get_resp.status()
);
}
#[tokio::test]
async fn test_presigned_url_expired() {
let server = setup_presigned_server().await;
let past_time = Utc::now() - chrono::Duration::minutes(20);
let timestamp = past_time.format("%Y%m%dT%H%M%SZ").to_string();
let date_str = past_time.format("%Y%m%d").to_string();
let credential = format!("testkey%2F{}%2Fus-east-1%2Fs3%2Faws4_request", date_str);
let query = format!(
"X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential={credential}\
&X-Amz-Date={timestamp}\
&X-Amz-Expires=60\
&X-Amz-SignedHeaders=host\
&X-Amz-Signature=0000000000000000000000000000000000000000000000000000000000000000"
);
let url = format!("{}/somebucket/somekey?{}", server.base_url, query);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for expired presigned URL, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_presigned_url_tampered_signature() {
let server = setup_presigned_server().await;
let host = server.base_url.trim_start_matches("http://").to_string();
let generator = PresignedUrlGenerator::new(
"testkey".to_string(),
"testsecret".to_string(),
"us-east-1".to_string(),
host.clone(),
);
let valid_url = generator.generate_presigned_get_url("tamperbucket", "tamper.txt", 3600);
let tampered_url = {
if let Some(sig_start) = valid_url.find("X-Amz-Signature=") {
let sig_value_start = sig_start + "X-Amz-Signature=".len();
let (prefix, sig_and_rest) = valid_url.split_at(sig_value_start);
let sig_len = sig_and_rest.len();
if sig_len >= 2 {
let new_sig: String = sig_and_rest[..sig_len - 2].to_string() + "XX";
format!("{}{}", prefix, new_sig)
} else {
valid_url.clone()
}
} else {
valid_url.clone()
}
};
let resp = reqwest::Client::new()
.get(&tampered_url)
.header("host", &host)
.send()
.await
.expect("HTTP request");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"Expected 403 for tampered presigned signature, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_chunked_put_unsigned_payload() {
let server = setup_auth_server("", "").await;
let client = reqwest::Client::new();
let bucket = "chunked-bucket";
let key = "chunked-object.txt";
let content = b"This is a chunked upload test payload.";
let _bucket_resp = client
.put(format!("{}/{}", server.base_url, bucket))
.send()
.await
.expect("create bucket");
let put_resp = client
.put(format!("{}/{}/{}", server.base_url, bucket, key))
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
.header("Transfer-Encoding", "chunked")
.body(reqwest::Body::from(content.as_ref()))
.send()
.await
.expect("PUT request with UNSIGNED-PAYLOAD");
assert!(
put_resp.status().is_success(),
"Expected 2xx for PUT with UNSIGNED-PAYLOAD, got {}",
put_resp.status()
);
let get_resp = client
.get(format!("{}/{}/{}", server.base_url, bucket, key))
.send()
.await
.expect("GET request");
assert!(
get_resp.status().is_success(),
"Expected 2xx when fetching object stored with UNSIGNED-PAYLOAD, got {}",
get_resp.status()
);
let body = get_resp.bytes().await.expect("read body");
assert_eq!(
body.as_ref(),
content,
"Retrieved object content must match what was PUT"
);
}
#[tokio::test]
async fn test_chunked_put_empty() {
let server = setup_auth_server("", "").await;
let client = reqwest::Client::new();
let bucket = "chunked-empty-bucket";
let key = "empty-object.bin";
let _bucket_resp = client
.put(format!("{}/{}", server.base_url, bucket))
.send()
.await
.expect("create bucket");
let put_resp = client
.put(format!("{}/{}/{}", server.base_url, bucket, key))
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
.header("content-length", "0")
.body(reqwest::Body::from(vec![]))
.send()
.await
.expect("empty PUT request");
assert!(
put_resp.status().is_success(),
"Expected 2xx for empty PUT with UNSIGNED-PAYLOAD, got {}",
put_resp.status()
);
}
#[test]
fn test_passthrough_mode_detect() {
assert_eq!(detect_auth_mode("", ""), AuthMode::Passthrough);
}
#[test]
fn test_authenticated_mode_detect() {
assert_eq!(detect_auth_mode("key", "secret"), AuthMode::Authenticated);
}
#[test]
fn test_misconfigured_mode_detect() {
assert_eq!(detect_auth_mode("key", ""), AuthMode::Misconfigured);
assert_eq!(detect_auth_mode("", "secret"), AuthMode::Misconfigured);
}
#[tokio::test]
async fn test_exempt_paths_bypass_auth() {
let server = setup_auth_server("mykey", "mysecret").await;
let client = reqwest::Client::new();
let health_resp = client
.get(format!("{}/health", server.base_url))
.send()
.await
.expect("GET /health");
assert_ne!(
health_resp.status(),
StatusCode::FORBIDDEN,
"/health must not return 403 (auth), got {}",
health_resp.status()
);
let metrics_resp = client
.get(format!("{}/metrics", server.base_url))
.send()
.await
.expect("GET /metrics");
assert_ne!(
metrics_resp.status(),
StatusCode::FORBIDDEN,
"/metrics must not return 403 (auth), got {}",
metrics_resp.status()
);
}