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
16 changes: 15 additions & 1 deletion crates/fakecloud-iam/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -882,9 +882,23 @@ fn principal_is_federated(principal: &Principal, provider: &str) -> bool {
/// host string. False matches are avoided because unrelated role
/// names would have to happen to contain `lambda.amazonaws.com` —
/// unlikely in practice and never silently grant to user principals.
/// True when `arn` denotes the given AWS service principal — i.e. it is a
/// service-linked-role identity under the AWS-reserved `aws-service-role/
/// <service>/` path. The reserved path segment cannot be forged as an ordinary
/// role name, so this distinguishes a genuine service principal from a role a
/// user merely named after a service host.
pub(crate) fn arn_denotes_service(arn: &str, service: &str) -> bool {
arn.contains(&format!("aws-service-role/{service}"))
}

fn principal_is_service(principal: &Principal, service: &str) -> bool {
// Match the bare service host *anywhere* in the ARN would let a user create
// a role literally named e.g. `lambda.amazonaws.com`, assume it, and have
// its `assumed-role/lambda.amazonaws.com/...` ARN satisfy a
// `Principal: { Service: lambda.amazonaws.com }` trust — a privilege
// escalation. Require the reserved service-linked-role path instead.
matches!(principal.principal_type, PrincipalType::AssumedRole)
&& principal.arn.contains(service)
&& arn_denotes_service(&principal.arn, service)
}

fn action_matches(action: &ActionMatch, request_action: &str) -> bool {
Expand Down
73 changes: 67 additions & 6 deletions crates/fakecloud-iam/src/evaluator_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1091,11 +1091,19 @@ fn principal_user_arn_mismatch_is_deny() {
}

#[test]
fn principal_service_matches_assumed_role_containing_service_host() {
let p = assumed_role_principal(
ACCT_A,
"AWSServiceRoleForLambda.lambda.amazonaws.com/session",
);
fn principal_service_matches_service_linked_role() {
// A genuine service principal is a service-linked-role identity under the
// reserved `aws-service-role/<service>/` path.
let slr = Principal {
arn: format!(
"arn:aws:iam::{ACCT_A}:role/aws-service-role/lambda.amazonaws.com/AWSServiceRoleForLambda"
),
user_id: "AROASLR".into(),
account_id: ACCT_A.into(),
principal_type: PrincipalType::AssumedRole,
source_identity: None,
tags: None,
};
let resource = json!({
"Statement": [{
"Effect": "Allow",
Expand All @@ -1105,9 +1113,16 @@ fn principal_service_matches_assumed_role_containing_service_host() {
}]
});
assert_eq!(
eval_cross(None, Some(resource), &p, ACCT_A),
eval_cross(None, Some(resource.clone()), &slr, ACCT_A),
Decision::Allow
);

// §5.6: a role a user merely NAMED after the service host must not match.
let impostor = assumed_role_principal(ACCT_A, "lambda.amazonaws.com/session");
assert_eq!(
eval_cross(None, Some(resource), &impostor, ACCT_A),
Decision::ImplicitDeny
);
}

#[test]
Expand Down Expand Up @@ -2152,3 +2167,49 @@ fn unseeded_aws_managed_policy_grants_nothing() {
"an unseeded managed ARN resolves to no document"
);
}

// --- §5.6 Service-principal trust must not be satisfied by a look-alike role -

fn principal_assumed_role(arn: &str) -> Principal {
Principal {
arn: arn.to_string(),
user_id: "AROA:sess".into(),
account_id: "123456789012".into(),
principal_type: PrincipalType::AssumedRole,
source_identity: None,
tags: None,
}
}

#[test]
fn service_principal_requires_service_linked_role_path() {
// A genuine service principal carries the reserved
// `aws-service-role/<service>/` path.
let slr = principal_assumed_role(
"arn:aws:iam::123456789012:role/aws-service-role/lambda.amazonaws.com/AWSServiceRoleForLambda",
);
assert!(principal_is_service(&slr, "lambda.amazonaws.com"));

// A user-created role merely NAMED after the service host must NOT satisfy
// a `Principal: { Service: lambda.amazonaws.com }` trust (§5.6).
let impostor =
principal_assumed_role("arn:aws:sts::123456789012:assumed-role/lambda.amazonaws.com/sess");
assert!(!principal_is_service(&impostor, "lambda.amazonaws.com"));

// And a plain unrelated role never matches.
let unrelated =
principal_assumed_role("arn:aws:sts::123456789012:assumed-role/my-app-role/sess");
assert!(!principal_is_service(&unrelated, "lambda.amazonaws.com"));
}

#[test]
fn arn_denotes_service_matches_only_reserved_path() {
assert!(arn_denotes_service(
"arn:aws:iam::123456789012:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
"ecs.amazonaws.com"
));
assert!(!arn_denotes_service(
"arn:aws:sts::123456789012:assumed-role/ecs.amazonaws.com/sess",
"ecs.amazonaws.com"
));
}
41 changes: 23 additions & 18 deletions crates/fakecloud-iam/src/sts_service/assume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,13 @@ impl StsService {
.path
.trim_start_matches("/aws-service-role/")
.trim_end_matches('/');
// Only a genuine service principal (identified by the reserved
// `aws-service-role/<service>/` path) may assume the SLR — a
// role a user named after the service host must not qualify.
let caller_is_service = req
.principal
.as_ref()
.map(|p| p.arn.contains(expected_service))
.map(|p| crate::evaluator::arn_denotes_service(&p.arn, expected_service))
.unwrap_or(false);
if !caller_is_service {
return Err(AwsServiceError::aws_error(
Expand Down Expand Up @@ -714,25 +717,27 @@ impl StsService {
);
let assumed_role_id_str = format!("{}:{}", role_id, role_session_name);

// If the named SAML provider IS registered, enforce its
// metadata-derived audience against the assertion's
// `<Audience>` claim. Unregistered providers fall through —
// tests still in the pre-F1 era (and AWS itself, when the
// provider was nuked between assertion issue and use) get a
// soft pass; the trust policy below still gates the call.
// When the named SAML provider IS registered and its metadata declares
// an audience (`entityID`), the assertion must carry a *matching*
// audience. Previously a missing audience claim slipped through and
// skipped the binding entirely, so an assertion with no `<Audience>`
// was accepted for a provider that pins one. A registered provider
// whose metadata declares no audience still skips the check by design
// (`expected_saml_audience` returns `None`), and an unregistered
// provider falls through here — fakecloud tolerates it, matching the
// recorded conformance baseline (`sts_assume_role_with_saml`), and there
// is no metadata to bind an audience against in that case anyway.
if let Some(provider) = find_saml_provider(&accounts, &saml_provider_arn) {
if let Some(expected_aud) = expected_saml_audience(&provider.saml_metadata_document) {
if let Some(ref got) = saml_claims.audience {
if got != &expected_aud {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidIdentityToken",
format!(
"SAML assertion audience '{got}' does not match SAML provider '{}'",
provider.arn
),
));
}
if saml_claims.audience.as_deref() != Some(expected_aud.as_str()) {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidIdentityToken",
format!(
"SAML assertion audience {:?} does not match the audience '{expected_aud}' declared by SAML provider '{}'",
saml_claims.audience, provider.arn
),
));
}
}
}
Expand Down
76 changes: 74 additions & 2 deletions crates/fakecloud-iam/src/sts_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,26 @@ mod tests {
arn
}

/// Register a SAML provider in state so `AssumeRoleWithSAML` accepts its
/// assertion. The metadata deliberately carries no `entityID`, so
/// `expected_saml_audience` returns `None` and the audience check is
/// skipped (mirroring a provider whose metadata declares no audience).
fn register_saml_provider_in_state(state: &SharedIamState, arn: &str) {
let mut accounts = state.write();
let s = accounts.get_or_create("123456789012");
s.saml_providers.insert(
arn.to_string(),
crate::state::SamlProvider {
arn: arn.to_string(),
name: arn.rsplit('/').next().unwrap_or("idp").to_string(),
saml_metadata_document: "fake-saml-metadata-no-entity-id".to_string(),
created_at: Utc::now(),
valid_until: Utc::now() + chrono::Duration::days(365),
tags: Vec::new(),
},
);
}

// ── GetCallerIdentity ──

#[tokio::test]
Expand Down Expand Up @@ -1185,6 +1205,7 @@ mod tests {
let (svc, state) = make_sts_service();
let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::123456789012:saml-provider/idp"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
register_saml_provider_in_state(&state, "arn:aws:iam::123456789012:saml-provider/idp");
let assertion = make_saml_assertion();
let req = sts_request(
"AssumeRoleWithSAML",
Expand All @@ -1205,7 +1226,8 @@ mod tests {
#[tokio::test]
async fn assume_role_with_saml_nonexistent_role_denied() {
// §5.2: a missing role must NOT fall through to credential minting.
let (svc, _state) = make_sts_service();
let (svc, state) = make_sts_service();
register_saml_provider_in_state(&state, "arn:aws:iam::123456789012:saml-provider/idp");
let assertion = make_saml_assertion();
let req = sts_request(
"AssumeRoleWithSAML",
Expand All @@ -1229,6 +1251,55 @@ mod tests {
);
}

#[tokio::test]
async fn assume_role_with_saml_missing_audience_rejected() {
// §5.7: when the provider metadata declares an audience (`entityID`),
// an assertion with no matching audience claim must be rejected rather
// than skipping the binding.
let (svc, state) = make_sts_service();
let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::123456789012:saml-provider/idp"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
// Provider metadata declaring an entityID -> expected audience.
{
let mut accounts = state.write();
let s = accounts.get_or_create("123456789012");
s.saml_providers.insert(
"arn:aws:iam::123456789012:saml-provider/idp".to_string(),
crate::state::SamlProvider {
arn: "arn:aws:iam::123456789012:saml-provider/idp".to_string(),
name: "idp".to_string(),
saml_metadata_document:
r#"<EntityDescriptor entityID="https://sp.example.com/saml">"#.to_string(),
created_at: Utc::now(),
valid_until: Utc::now() + chrono::Duration::days(365),
tags: Vec::new(),
},
);
}
// `make_saml_assertion` carries no <Audience> claim.
let assertion = make_saml_assertion();
let req = sts_request(
"AssumeRoleWithSAML",
vec![
("RoleArn", &role_arn),
(
"PrincipalArn",
"arn:aws:iam::123456789012:saml-provider/idp",
),
("SAMLAssertion", &assertion),
],
);
let err = match svc.handle(req).await {
Err(e) => e,
Ok(_) => panic!("expected InvalidIdentityToken for missing audience"),
};
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
assert!(
format!("{err:?}").contains("audience"),
"expected audience-mismatch error, got {err:?}"
);
}

// ── GetSessionToken ──

#[tokio::test]
Expand Down Expand Up @@ -1668,9 +1739,10 @@ mod tests {
let (svc, state) = make_sts_service();
let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
let provider_arn = "arn:aws:iam::123456789012:saml-provider/idp";
register_saml_provider_in_state(&state, provider_arn);
let saml_xml = r#"<?xml version="1.0"?><samlp:Response><Assertion><AttributeStatement><Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><AttributeValue>jane</AttributeValue></Attribute></AttributeStatement></Assertion></samlp:Response>"#;
let saml_b64 = base64::engine::general_purpose::STANDARD.encode(saml_xml);
let provider_arn = "arn:aws:iam::123456789012:saml-provider/idp";
let req = sts_request(
"AssumeRoleWithSAML",
vec![
Expand Down
Loading