Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 3 additions & 1 deletion crates/fakecloud-dynamodb/src/service/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -772,8 +772,10 @@ impl DynamoDbService {
"Policy": policy,
"RevisionId": policy_revision_id(policy)
})),
// DynamoDB is awsJson1.0 — client errors are HTTP 400 with the
// error type in the body, never 404.
None => Err(AwsServiceError::aws_error(
StatusCode::NOT_FOUND,
StatusCode::BAD_REQUEST,
"PolicyNotFoundException",
"No resource-based policy is attached to the resource.",
)),
Expand Down
6 changes: 5 additions & 1 deletion crates/fakecloud-dynamodb/src/service/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,11 @@ fn resource_policy_lifecycle() {
// Get should now return PolicyNotFoundException, matching real DynamoDB.
let req = make_request("GetResourcePolicy", json!({ "ResourceArn": table_arn }));
match svc.get_resource_policy(&req) {
Err(e) => assert_eq!(e.code(), "PolicyNotFoundException"),
Err(e) => {
assert_eq!(e.code(), "PolicyNotFoundException");
// awsJson1.0: client errors are HTTP 400, never 404.
assert_eq!(e.status(), http::StatusCode::BAD_REQUEST);
}
Ok(_) => panic!("GetResourcePolicy after delete must error"),
}
}
Expand Down
22 changes: 11 additions & 11 deletions crates/fakecloud-iam/src/sts_service/assume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ impl StsService {
"The request must contain the parameter RoleArn",
)
})?;
validate_string_length("roleArn", role_arn, 20, 2048)?;
sts_validate_string_length("roleArn", role_arn, 20, 2048)?;

let role_session_name = req.query_params.get("RoleSessionName").ok_or_else(|| {
AwsServiceError::aws_error(
Expand All @@ -20,7 +20,7 @@ impl StsService {
"The request must contain the parameter RoleSessionName",
)
})?;
validate_string_length("roleSessionName", role_session_name, 2, 64)?;
sts_validate_string_length("roleSessionName", role_session_name, 2, 64)?;
validate_session_name(role_session_name)?;

// Validate optional DurationSeconds (used below for expiration)
Expand All @@ -36,7 +36,7 @@ impl StsService {
),
)
})?;
validate_range_i64("durationSeconds", v, 900, 43200)?;
sts_validate_range_i64("durationSeconds", v, 900, 43200)?;
}

// Validate optional ExternalId
Expand Down Expand Up @@ -318,7 +318,7 @@ impl StsService {
"The request must contain the parameter RoleArn",
)
})?;
validate_string_length("roleArn", role_arn, 20, 2048)?;
sts_validate_string_length("roleArn", role_arn, 20, 2048)?;

let role_session_name = req.query_params.get("RoleSessionName").ok_or_else(|| {
AwsServiceError::aws_error(
Expand All @@ -327,7 +327,7 @@ impl StsService {
"The request must contain the parameter RoleSessionName",
)
})?;
validate_string_length("roleSessionName", role_session_name, 2, 64)?;
sts_validate_string_length("roleSessionName", role_session_name, 2, 64)?;
validate_session_name(role_session_name)?;

// WebIdentityToken is required
Expand All @@ -338,7 +338,7 @@ impl StsService {
"The request must contain the parameter WebIdentityToken",
)
})?;
validate_string_length("webIdentityToken", web_identity_token, 4, 20000)?;
sts_validate_string_length("webIdentityToken", web_identity_token, 4, 20000)?;
let web_identity_token_owned = web_identity_token.clone();

// Validate optional Policy
Expand Down Expand Up @@ -370,7 +370,7 @@ impl StsService {
),
)
})?;
validate_range_i64("durationSeconds", v, 900, 43200)?;
sts_validate_range_i64("durationSeconds", v, 900, 43200)?;
}

// Compute expiration from DurationSeconds (default 3600s)
Expand Down Expand Up @@ -630,7 +630,7 @@ impl StsService {
"The request must contain the parameter RoleArn",
)
})?;
validate_string_length("roleArn", role_arn, 20, 2048)?;
sts_validate_string_length("roleArn", role_arn, 20, 2048)?;

// PrincipalArn is required
let principal_arn = req.query_params.get("PrincipalArn").ok_or_else(|| {
Expand All @@ -640,7 +640,7 @@ impl StsService {
"The request must contain the parameter PrincipalArn",
)
})?;
validate_string_length("principalArn", principal_arn, 20, 2048)?;
sts_validate_string_length("principalArn", principal_arn, 20, 2048)?;
// Snapshot the SAML provider ARN so we can stash it on the
// session as `aws:FederatedProvider` after this scope ends.
let saml_provider_arn = principal_arn.clone();
Expand All @@ -653,7 +653,7 @@ impl StsService {
"The request must contain the parameter SAMLAssertion",
)
})?;
validate_string_length("sAMLAssertion", saml_assertion, 4, 100000)?;
sts_validate_string_length("sAMLAssertion", saml_assertion, 4, 100000)?;

// Validate optional Policy
validate_optional_string_length(
Expand All @@ -676,7 +676,7 @@ impl StsService {
),
)
})?;
validate_range_i64("durationSeconds", v, 900, 43200)?;
sts_validate_range_i64("durationSeconds", v, 900, 43200)?;
}

// Compute expiration from DurationSeconds (default 3600s)
Expand Down
2 changes: 1 addition & 1 deletion crates/fakecloud-iam/src/sts_service/caller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ impl StsService {
"The request must contain the parameter AccessKeyId",
)
})?;
validate_string_length("accessKeyId", access_key_id, 16, 128)?;
sts_validate_string_length("accessKeyId", access_key_id, 16, 128)?;

// Try to resolve account from known access keys across all accounts
let accounts = self.state.read();
Expand Down
4 changes: 2 additions & 2 deletions crates/fakecloud-iam/src/sts_service/federation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ impl StsService {
"The request must contain the parameter Name",
)
})?;
validate_string_length("name", name, 2, 32)?;
sts_validate_string_length("name", name, 2, 32)?;

// Validate optional DurationSeconds (used below for expiration)
if let Some(ds) = req.query_params.get("DurationSeconds") {
Expand All @@ -29,7 +29,7 @@ impl StsService {
),
)
})?;
validate_range_i64("durationSeconds", v, 900, 129600)?;
sts_validate_range_i64("durationSeconds", v, 900, 129600)?;
}

// Validate and store optional policy
Expand Down
49 changes: 49 additions & 0 deletions crates/fakecloud-iam/src/sts_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,37 @@ use crate::state::{CredentialIdentity, IamState, SharedIamState, StsTempCredenti
use crate::xml_responses::{self, StsCredentials};
use fakecloud_core::auth::{Principal, PrincipalType};

/// STS speaks the awsQuery protocol, whose constraint violations surface as
/// `ValidationError` (no `Exception` suffix). The shared awsJson validators
/// emit `ValidationException`; these wrappers reuse their logic/messages but
/// re-stamp the code so STS matches the AWS wire shape (bug-hunt: STS length /
/// range checks were leaking `ValidationException`).
fn to_validation_error(e: AwsServiceError) -> AwsServiceError {
if e.code() == "ValidationException" {
AwsServiceError::aws_error(e.status(), "ValidationError", e.message())
} else {
e
}
}

fn sts_validate_string_length(
field: &str,
value: &str,
min: usize,
max: usize,
) -> Result<(), AwsServiceError> {
validate_string_length(field, value, min, max).map_err(to_validation_error)
}

fn sts_validate_range_i64(
field: &str,
value: i64,
min: i64,
max: i64,
) -> Result<(), AwsServiceError> {
validate_range_i64(field, value, min, max).map_err(to_validation_error)
}

/// Default duration for AssumeRole and similar operations (1 hour).
const DEFAULT_ASSUME_ROLE_DURATION: i64 = 3600;

Expand Down Expand Up @@ -1861,6 +1892,24 @@ mod tests {
assert!(svc.handle(req).await.is_err());
}

#[tokio::test]
async fn assume_role_length_violation_uses_query_protocol_error_code() {
// STS is awsQuery: a length-constraint violation must surface as
// `ValidationError`, not the awsJson `ValidationException` the shared
// validator emits (bug-hunt LOW error-code).
let (svc, _) = make_sts_service();
// RoleArn shorter than the 20-char minimum.
let req = sts_request(
"AssumeRole",
vec![("RoleArn", "arn:short"), ("RoleSessionName", "sess")],
);
let err = svc
.assume_role(&req)
.err()
.expect("too-short RoleArn errors");
assert_eq!(err.code(), "ValidationError");
}

#[tokio::test]
async fn assume_role_missing_role_arn_errors() {
let (svc, _) = make_sts_service();
Expand Down
4 changes: 2 additions & 2 deletions crates/fakecloud-iam/src/sts_service/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ impl StsService {
),
)
})?;
validate_range_i64("durationSeconds", v, 900, 129600)?;
sts_validate_range_i64("durationSeconds", v, 900, 129600)?;
}

// Validate and accept optional MFA SerialNumber (no verification in emulator)
Expand Down Expand Up @@ -118,7 +118,7 @@ impl StsService {
"The request must contain the parameter EncodedMessage",
)
})?;
validate_string_length("encodedMessage", encoded_message, 1, 10240)?;
sts_validate_string_length("encodedMessage", encoded_message, 1, 10240)?;

// Round-trip the deflated/base64'd token produced by
// `auth_message::encode_deny`. Tokens that don't decode are
Expand Down
9 changes: 4 additions & 5 deletions crates/fakecloud-s3/src/service/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,10 @@ impl S3Service {
upload_id: &str,
part_number: i64,
) -> Result<AwsResponse, AwsServiceError> {
// Validate part number
if part_number < 1 {
return Err(no_such_upload(upload_id));
}
if part_number > 10000 {
// Validate part number. An out-of-range partNumber is an invalid
// argument (400 InvalidArgument), not a missing upload (404) — the
// low end previously returned NoSuchUpload.
if !(1..=10000).contains(&part_number) {
return Err(AwsServiceError::aws_error_with_fields(
StatusCode::BAD_REQUEST,
"InvalidArgument",
Expand Down
15 changes: 9 additions & 6 deletions crates/fakecloud-s3/src/service/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,13 +842,16 @@ async fn upload_part_rejects_invalid_part_number() {
seed_bucket(&svc, "b");
let upload_id = initiate_mpu(&svc, "b", "k");

// part_number < 1 is masked as NoSuchUpload (matching AWS behavior).
// part_number < 1 is an out-of-range argument: 400 InvalidArgument, same
// as the > 10000 case (previously masked as a 404 NoSuchUpload).
let req = make_request(Method::PUT, "/b/k", &[("partNumber", "0")], b"body");
assert_aws_err(
svc.upload_part("123456789012", &req, "b", "k", &upload_id, 0)
.await,
"NoSuchUpload",
);
let err = svc
.upload_part("123456789012", &req, "b", "k", &upload_id, 0)
.await
.err()
.expect("partNumber 0 must error");
assert_eq!(err.code(), "InvalidArgument");
assert_eq!(err.status(), StatusCode::BAD_REQUEST);

// part_number > 10000 returns InvalidArgument.
let req2 = make_request(Method::PUT, "/b/k", &[("partNumber", "10001")], b"body");
Expand Down
6 changes: 3 additions & 3 deletions crates/fakecloud-secretsmanager/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1215,14 +1215,14 @@ impl SecretsManagerService {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidParameterException",
"InvalidParameterException",
"Invalid value for parameter PasswordLength; must be 4..=4096.",
));
}
if length > 4096 {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidParameterValue",
"InvalidParameterValue",
"InvalidParameterException",
"Invalid value for parameter PasswordLength; must be 4..=4096.",
));
}

Expand Down
8 changes: 7 additions & 1 deletion crates/fakecloud-secretsmanager/src/service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,13 @@ async fn test_get_random_password_too_long() {
let svc = SecretsManagerService::new(state);

let req = make_request("GetRandomPassword", r#"{"PasswordLength": 4097}"#);
assert!(svc.handle(req).await.is_err());
let err = svc
.handle(req)
.await
.err()
.expect("over-long PasswordLength errors");
// AWS SecretsManager uses InvalidParameterException, not InvalidParameterValue.
assert_eq!(err.code(), "InvalidParameterException");
}

#[tokio::test]
Expand Down
5 changes: 3 additions & 2 deletions crates/fakecloud-sns/src/service_publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ impl SnsService {
let message_dedup_id = param(req, "MessageDeduplicationId");
let message_structure = param(req, "MessageStructure");

// Validate subject length
// Validate subject length. AWS caps Subject at 100 *characters*, not
// bytes — count chars so a multibyte subject is not wrongly rejected.
if let Some(ref subj) = subject {
if subj.len() > 100 {
if subj.chars().count() > 100 {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidParameter",
Expand Down
34 changes: 34 additions & 0 deletions crates/fakecloud-sns/src/service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,40 @@ fn list_subscriptions_by_topic_filters_correctly() {

// --- Publish / PublishBatch ---

#[test]
fn publish_subject_length_counts_characters_not_bytes() {
// AWS caps Subject at 100 characters. A 100-char multibyte subject is 200
// bytes; a byte-length check wrongly rejected it. A 101-char subject is
// still rejected.
let (svc, _state) = make_sns();
assert_ok(&svc.create_topic(&sns_request("CreateTopic", vec![("Name", "subj-topic")])));
let topic_arn = "arn:aws:sns:us-east-1:123456789012:subj-topic";

let ok_subject = "é".repeat(100); // 100 chars, 200 bytes
assert_ok(&svc.publish(&sns_request(
"Publish",
vec![
("TopicArn", topic_arn),
("Message", "hi"),
("Subject", &ok_subject),
],
)));

let too_long = "é".repeat(101);
let err = svc
.publish(&sns_request(
"Publish",
vec![
("TopicArn", topic_arn),
("Message", "hi"),
("Subject", &too_long),
],
))
.err()
.expect("101-char subject must be rejected");
assert_eq!(err.code(), "InvalidParameter");
}

#[test]
fn publish_to_topic_stores_message() {
let (svc, state) = make_sns();
Expand Down
Loading