#![allow(unused_imports)]
mod common;
use bucketwarden_s3::{
sigv4::{authorization_header, presigned_url_query, sha256_hex, AwsCredentials, SigV4Request},
S3HttpRequest,
};
use bucketwarden_server::{
s3_service_specific_error_catalog, BucketWarden, OperatorRole, RuntimeConfig,
};
use common::*;
const HOST: &str = "s3.bucketwarden.test";
const AMZ_DATE: &str = "20130524T000000Z";
const SCOPE_DATE: &str = "20130524";
const NOW: u64 = 1_369_353_620;
#[test]
fn http_sigv4_surface_reports_specific_s3_general_error_codes() {
let mut runtime = BucketWarden::new(RuntimeConfig::development()).expect("runtime");
runtime.set_clock_epoch_seconds(NOW);
runtime.allow("alice", "s3:*", "*");
runtime.create_local_user("alice");
runtime
.create_access_key("alice", "AKIAOK", "ok-secret")
.expect("ok key");
runtime
.create_access_key_with_expiry("alice", "AKIAEXPIRED", "expired-secret", NOW - 1)
.expect("expired key");
runtime
.create_session_credential(
"alice",
"AKIAOK",
"ASIATEMP",
"session-secret",
"session-token",
NOW + 60,
)
.expect("session");
runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
.expect("bucket");
let malformed_query = runtime
.handle_s3_http(
S3HttpRequest::new("anonymous", "GET", "/archive-001")
.with_header("host", HOST)
.with_query("X-Amz-Signature", "abc123"),
)
.expect("malformed query");
assert_labeled_error_response(
"malformed_query",
&malformed_query,
400,
"AuthorizationQueryParametersError",
false,
);
let malformed_header = runtime
.handle_s3_http(
S3HttpRequest::new("anonymous", "GET", "/archive-001")
.with_header("host", HOST)
.with_header(
"authorization",
"AWS4-HMAC-SHA256 SignedHeaders=host;x-amz-date, Signature=abc123",
)
.with_header("x-amz-date", AMZ_DATE)
.with_header("x-amz-content-sha256", sha256_hex(b"")),
)
.expect("malformed authorization");
assert_labeled_error_response(
"malformed_header",
&malformed_header,
400,
"AuthorizationHeaderMalformed",
false,
);
let invalid_access_key = runtime
.handle_s3_http(signed_get(
"/archive-001",
&AwsCredentials::new("AKIAUNKNOWN", "unknown-secret"),
))
.expect("invalid access key");
assert_labeled_error_response(
"invalid_access_key",
&invalid_access_key,
403,
"InvalidAccessKeyId",
false,
);
let expired = runtime
.handle_s3_http(signed_get(
"/archive-001",
&AwsCredentials::new("AKIAEXPIRED", "expired-secret"),
))
.expect("expired access key");
assert_labeled_error_response("expired", &expired, 400, "ExpiredToken", false);
let credentials = AwsCredentials::session("ASIATEMP", "session-secret", "session-token");
let bad_token = runtime
.handle_s3_http(signed_get_with_session_token(
"/archive-001",
&credentials,
"wrong-token",
))
.expect("bad token");
assert_labeled_error_response("bad_token", &bad_token, 400, "InvalidToken", false);
let base = SigV4Request::new("GET", "/archive-001")
.with_header("host", HOST)
.with_payload_hash("UNSIGNED-PAYLOAD");
let mut invalid_timestamp =
S3HttpRequest::new("anonymous", "GET", "/archive-001").with_header("host", HOST);
for (key, value) in presigned_url_query(
&base,
&AwsCredentials::new("AKIAOK", "ok-secret"),
AMZ_DATE,
SCOPE_DATE,
"us-east-1",
"s3",
60,
&["host"],
)
.expect("presign")
{
let value = if key == "X-Amz-Date" {
"not-a-timestamp".to_string()
} else {
value
};
invalid_timestamp = invalid_timestamp.with_query(key, value);
}
let invalid_timestamp = runtime
.handle_s3_http(invalid_timestamp)
.expect("invalid timestamp");
assert_labeled_error_response(
"invalid_timestamp",
&invalid_timestamp,
403,
"RequestTimeTooSkewed",
false,
);
}
#[test]
fn s3_root_access_denied_error_has_matching_content_length() {
let mut runtime = BucketWarden::new(RuntimeConfig::development()).expect("runtime");
runtime.allow("alice", "s3:*", "*");
runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
.expect("bucket");
let anonymous = runtime
.handle_s3_http(S3HttpRequest::new("anonymous", "GET", "/"))
.expect("root access denied");
let body = String::from_utf8(anonymous.body.clone()).expect("xml");
assert_eq!(anonymous.status, 403);
assert_eq!(
anonymous.headers.get("content-type"),
Some(&"application/xml".to_string())
);
assert_eq!(
anonymous.headers.get("content-length"),
Some(&anonymous.body.len().to_string())
);
assert!(body.starts_with("<Error>"));
assert!(body.ends_with("</Error>"));
assert!(body.contains("<Code>AccessDenied</Code>"));
assert!(body.contains("<RequestId>"));
assert!(body.contains("<HostId>"));
let authorized = runtime
.handle_s3_http(S3HttpRequest::new("alice", "GET", "/"))
.expect("root list buckets");
let authorized_body = String::from_utf8(authorized.body.clone()).expect("xml");
assert_eq!(authorized.status, 200);
assert_eq!(
authorized.headers.get("content-type"),
Some(&"application/xml".to_string())
);
assert!(authorized_body.starts_with("<ListAllMyBucketsResult>"));
assert!(authorized_body.contains("<Name>archive-001</Name>"));
assert!(!authorized_body.contains("<!doctype html"));
assert!(!authorized_body.contains("<html"));
}
fn assert_labeled_error_response(
label: &str,
response: &bucketwarden_s3::S3HttpResponse,
status: u16,
code: &str,
retryable: bool,
) {
let body = String::from_utf8(response.body.clone()).expect("xml");
assert_eq!(
response.status, status,
"{label}: unexpected status with body {body}"
);
assert_eq!(
response.headers.get("x-bucketwarden-error-retryable"),
Some(&retryable.to_string()),
"{label}: unexpected retryability with body {body}"
);
assert!(
body.contains(&format!("<Code>{code}</Code>")),
"{label}: expected code {code} but body was {body}"
);
assert!(
body.contains("<RequestId>bwreq"),
"{label}: missing request id in body {body}"
);
assert!(
body.contains("<HostId>"),
"{label}: missing host id in body {body}"
);
}
fn signed_authorization(path: &str, credentials: &AwsCredentials, amz_date: &str) -> String {
let payload_hash = sha256_hex(b"");
let request = SigV4Request::new("GET", path)
.with_header("host", HOST)
.with_header("x-amz-date", amz_date)
.with_header("x-amz-content-sha256", payload_hash.clone())
.with_payload_hash(payload_hash);
authorization_header(
&request,
credentials,
amz_date,
SCOPE_DATE,
"us-east-1",
"s3",
&["host", "x-amz-content-sha256", "x-amz-date"],
)
.expect("authorization")
}
fn signed_get(path: &str, credentials: &AwsCredentials) -> S3HttpRequest {
let payload_hash = sha256_hex(b"");
S3HttpRequest::new("anonymous", "GET", path)
.with_header("host", HOST)
.with_header("x-amz-date", AMZ_DATE)
.with_header("x-amz-content-sha256", payload_hash)
.with_header(
"authorization",
signed_authorization(path, credentials, AMZ_DATE),
)
}
fn signed_get_with_session_token(
path: &str,
credentials: &AwsCredentials,
provided_token: &str,
) -> S3HttpRequest {
let payload_hash = sha256_hex(b"");
let request = SigV4Request::new("GET", path)
.with_header("host", HOST)
.with_header("x-amz-date", AMZ_DATE)
.with_header("x-amz-content-sha256", payload_hash.clone())
.with_header("x-amz-security-token", provided_token)
.with_payload_hash(payload_hash.clone());
let authorization = authorization_header(
&request,
credentials,
AMZ_DATE,
SCOPE_DATE,
"us-east-1",
"s3",
&[
"host",
"x-amz-content-sha256",
"x-amz-date",
"x-amz-security-token",
],
)
.expect("authorization");
S3HttpRequest::new("anonymous", "GET", path)
.with_header("host", HOST)
.with_header("x-amz-date", AMZ_DATE)
.with_header("x-amz-content-sha256", payload_hash)
.with_header("x-amz-security-token", provided_token)
.with_header("authorization", authorization)
}
#[test]
fn http_method_not_allowed_errors_are_general_family_at_service_bucket_and_object_layers() {
let mut runtime = runtime();
runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
.expect("create bucket");
runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-001/readonly.txt")
.with_body(b"immutable".to_vec()),
)
.expect("put object");
let service = runtime
.handle_s3_http(S3HttpRequest::new("alice", "PATCH", "/"))
.expect("service method not allowed");
assert_error_response_with_family(&service, 405, "MethodNotAllowed", "general", false);
let bucket = runtime
.handle_s3_http(S3HttpRequest::new("alice", "PATCH", "/archive-001"))
.expect("bucket method not allowed");
assert_error_response_with_family(&bucket, 405, "MethodNotAllowed", "general", false);
let object = runtime
.handle_s3_http(S3HttpRequest::new(
"alice",
"PATCH",
"/archive-001/readonly.txt",
))
.expect("object method not allowed");
assert_error_response_with_family(&object, 405, "MethodNotAllowed", "general", false);
}
#[test]
fn runtime_service_s3err_queries_cover_all_general_features_t0_grade() {
let mut runtime = runtime();
runtime.create_local_user("alice");
runtime
.assign_operator_role("alice", "alice", OperatorRole::ClusterAdmin, "*")
.expect("assign cluster-admin");
let general_features: Vec<_> = s3_service_specific_error_catalog()
.iter()
.filter(|error| error.family == "general")
.collect();
assert!(!general_features.is_empty(), "no general features found");
for chunk in general_features.chunks(25) {
for feature in chunk {
let response = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/")
.with_query("x-bucketwarden-s3err-feature", feature.feature_id),
)
.expect("query-driven service-specific error");
assert_error_response_with_family(
&response,
feature.status,
feature.code,
feature.family,
feature.status == 500 || feature.status == 503,
);
}
}
let code_query = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/")
.with_query("x-bucketwarden-s3err-code", "NoSuchBucket")
.with_query("x-bucketwarden-s3err-family", "general"),
)
.expect("code/family query");
assert_error_response_with_family(&code_query, 404, "NoSuchBucket", "general", false);
let empty_feature = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/").with_query("x-bucketwarden-s3err-feature", ""),
)
.expect("empty-feature query");
assert_error_response(&empty_feature, 400, "InvalidRequest", false);
let unknown_feature = runtime
.handle_s3_http(S3HttpRequest::new("alice", "GET", "/").with_query(
"x-bucketwarden-s3err-feature",
"feat:bucketwarden.s3err.general.dne",
))
.expect("unknown feature");
assert_error_response(&unknown_feature, 400, "InvalidRequest", false);
}
#[test]
fn runtime_service_s3err_query_requires_operator_read_diagnostics_role() {
let mut runtime = runtime();
let denied = runtime
.handle_s3_http(S3HttpRequest::new("alice", "GET", "/").with_query(
"x-bucketwarden-s3err-feature",
"feat:bucketwarden.s3err.general.nosuchbucket",
))
.expect("operator denied");
assert_error_response(&denied, 403, "AccessDenied", false);
runtime.create_local_user("alice");
runtime
.assign_operator_role("alice", "alice", OperatorRole::ClusterAdmin, "*")
.expect("assign cluster-admin");
let allowed = runtime
.handle_s3_http(S3HttpRequest::new("alice", "GET", "/").with_query(
"x-bucketwarden-s3err-feature",
"feat:bucketwarden.s3err.general.nosuchbucket",
))
.expect("operator allowed");
assert_error_response(&allowed, 404, "NoSuchBucket", false);
}