Skip to content

Commit 369ae6c

Browse files
authored
fix(core,s3): explicit-Deny beats public ACL + strict-mode deny on unmapped action (bug-hunt) (#2296)
2 parents efd08cf + 5ca7105 commit 369ae6c

2 files changed

Lines changed: 119 additions & 5 deletions

File tree

crates/fakecloud-core/src/dispatch.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -710,16 +710,39 @@ pub async fn dispatch(
710710
// through to the handler.
711711
}
712712
} else {
713-
// Service opted in but didn't return an IamAction
714-
// for this specific operation — programming bug,
715-
// surface it loudly in soft/strict mode so it's
716-
// visible during rollout.
713+
// Service opted in via `iam_enforceable()` but its
714+
// `iam_action_for` returned no `IamAction` for this
715+
// specific operation (e.g. S3's `s3_detect_action`
716+
// has `_ => return None` arms for unrecognized
717+
// sub-resources). Under strict enforcement that must
718+
// fail closed: an operation we cannot map to an IAM
719+
// action cannot be authorized, so serving it would be
720+
// a fail-open bypass of `--iam strict`. Deny by
721+
// default. In soft mode we preserve the historical
722+
// warn-and-allow so an incomplete mapping surfaces
723+
// during rollout without blocking traffic.
717724
tracing::warn!(
718725
target: "fakecloud::iam::audit",
719726
service = %detected.service,
720727
action = %aws_request.action,
721-
"service is iam_enforceable but has no IamAction mapping for this action; skipping evaluation"
728+
mode = %config.iam_mode,
729+
request_id = %request_id,
730+
"service is iam_enforceable but has no IamAction mapping for this action; denying under strict, allowing under soft"
722731
);
732+
if config.iam_mode.is_strict() {
733+
return build_error_response(
734+
StatusCode::FORBIDDEN,
735+
"AccessDeniedException",
736+
&format!(
737+
"User: {} is not authorized to perform: {}: no IAM action mapping exists for this operation, so it cannot be authorized under strict IAM enforcement",
738+
principal.arn, aws_request.action,
739+
),
740+
&request_id,
741+
detected.protocol,
742+
);
743+
}
744+
// Soft mode: audit log emitted; fall through to the
745+
// handler.
723746
}
724747
}
725748
} else if aws_request.access_key_id.is_none() {

crates/fakecloud-e2e/tests/iam_enforcement.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,97 @@ async fn bucket_policy_identity_allow_no_bucket_policy_still_works() {
907907
.unwrap();
908908
}
909909

910+
/// A hand-crafted signed Authorization header carrying `akid`'s access key in
911+
/// the S3 credential scope. With SigV4 verification off the dummy signature is
912+
/// never checked, but dispatch still parses the access key and resolves the
913+
/// principal — letting a test drive requests that the AWS SDK would never
914+
/// emit (here, an S3 sub-resource form with no IAM-action mapping).
915+
fn s3_signed_auth_header(akid: &str) -> String {
916+
format!(
917+
"AWS4-HMAC-SHA256 Credential={akid}/20240101/us-east-1/s3/aws4_request, \
918+
SignedHeaders=host, Signature=deadbeef"
919+
)
920+
}
921+
922+
#[tokio::test]
923+
async fn strict_mode_denies_unmapped_enforceable_action() {
924+
// Fix 5.2: strict enforcement must fail closed when an `iam_enforceable`
925+
// service returns no `IamAction` for an operation. `POST /bucket?acl` is
926+
// not a valid S3 op (ACL is GET/PUT only), so `s3_detect_action` returns
927+
// None — the enforceable-but-unmapped branch. Previously dispatch warned
928+
// and fell through to the handler, running the op with no policy check.
929+
//
930+
// SigV4 verification is left OFF so the hand-crafted request reaches the
931+
// IAM layer (a bad signature would otherwise 403 earlier, masking the
932+
// behavior under test).
933+
let server = TestServer::start_with_env(&[("FAKECLOUD_IAM", "strict")]).await;
934+
935+
let boot = sdk_config_with(&server, "test", "test").await;
936+
aws_sdk_s3::Client::new(&boot)
937+
.create_bucket()
938+
.bucket("unmapped-probe")
939+
.send()
940+
.await
941+
.unwrap();
942+
943+
let (akid, _secret) = bootstrap_user(&server, "unmapped_user").await;
944+
// Allow-all identity policy: any *mapped* action would be permitted, so a
945+
// 403 here can only come from the missing IAM-action mapping, not a policy
946+
// deny. This isolates the fail-closed behavior.
947+
attach_inline_policy(
948+
&server,
949+
"unmapped_user",
950+
"AllowEverything",
951+
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}"#,
952+
)
953+
.await;
954+
955+
let http = reqwest::Client::new();
956+
let resp = http
957+
.post(format!("{}/unmapped-probe?acl", server.endpoint()))
958+
.header("authorization", s3_signed_auth_header(&akid))
959+
.send()
960+
.await
961+
.unwrap();
962+
assert_eq!(
963+
resp.status(),
964+
403,
965+
"strict mode must deny an enforceable action with no IAM-action mapping (fail closed)"
966+
);
967+
}
968+
969+
#[tokio::test]
970+
async fn soft_mode_allows_unmapped_enforceable_action_through() {
971+
// Counterpart guarantee to `strict_mode_denies_unmapped_enforceable_action`:
972+
// the new deny is gated on strict mode only. In soft mode the same unmapped
973+
// enforceable action is NOT denied by the IAM layer — it warns and falls
974+
// through to the handler, preserving historical rollout behavior.
975+
let server = TestServer::start_with_env(&[("FAKECLOUD_IAM", "soft")]).await;
976+
977+
let boot = sdk_config_with(&server, "test", "test").await;
978+
aws_sdk_s3::Client::new(&boot)
979+
.create_bucket()
980+
.bucket("unmapped-soft-probe")
981+
.send()
982+
.await
983+
.unwrap();
984+
985+
let (akid, _secret) = bootstrap_user(&server, "unmapped_soft_user").await;
986+
987+
let http = reqwest::Client::new();
988+
let resp = http
989+
.post(format!("{}/unmapped-soft-probe?acl", server.endpoint()))
990+
.header("authorization", s3_signed_auth_header(&akid))
991+
.send()
992+
.await
993+
.unwrap();
994+
assert_ne!(
995+
resp.status(),
996+
403,
997+
"soft mode must not deny an unmapped enforceable action; it falls through to the handler"
998+
);
999+
}
1000+
9101001
#[tokio::test]
9111002
async fn bucket_policy_principal_wildcard_grants_any_user() {
9121003
// Public bucket idiom: `"Principal": "*"`. Any non-root caller

0 commit comments

Comments
 (0)