Surfaced during the review of LeanerCloud/cloud-commitments-cli#1818 (which closes LeanerCloud/cloud-commitments-cli#1723). Pre-existing, not introduced by that PR, which changed no Resource, Condition or ARN line. Filed separately rather than widening LeanerCloud/cloud-commitments-cli#1818's scope.
What
terraform/environments/aws/ci-cd-permissions/policy_data.tf scopes several statements with a trailing-wildcard role ARN:
:61 arn:aws:iam::*:role/cudly-* (with policy/cudly-* and instance-profile/cudly-*)
:77 arn:aws:iam::*:role/cudly-*, the iam:PassRole statement, conditioned on iam:PassedToService
In an IAM resource ARN, * matches any sequence of characters including /, and the IAM role path is part of the ARN. So arn:aws:iam::123456789012:role/cudly-x/EvilRole satisfies arn:aws:iam::*:role/cudly-*. The prefix that is supposed to identify our roles can be carried in the path, which is a field the role's creator controls, rather than in the role name.
The same shape applies to arn:aws:iam::*:role/CUDly* in CrossAccountAssumeRoleCeiling. That one additionally has drift protection from TestBoundaryMatchesCrossAccountRolePrefix, but the path-matching property is identical. Note this is a different concern from LeanerCloud/cloud-commitments-cli#1628 item 4 / LeanerCloud/cloud-commitments-cli#1636, which is about the missing per-tenant account condition on sts:AssumeRole, not about path-bearing ARNs.
Why it matters
This is the classic IAM path wildcard bypass: an attacker satisfies a name-prefix guard by putting the required prefix in a segment they choose.
It is currently contained, and the containment is worth stating precisely so nobody over-reacts or under-reacts:
So this is not a live escape today. It is a guard whose correctness depends entirely on a separate control staying in place, which is exactly the kind of coupling that breaks silently later.
Fix direction
Prefer a form that cannot be satisfied by a path segment. Options, roughly in order of preference:
- Constrain the path explicitly, e.g. require
arn:aws:iam::*:role/cudly-* and add an iam:ResourceTag or a path condition that pins the expected path, so the name prefix is not the only thing being checked.
- Pin the path in the ARN itself (
role/cudly/…) and match on the full, expected shape rather than a trailing wildcard.
- At minimum, add a guard test asserting that a path-bearing ARN such as
arn:aws:iam::123456789012:role/cudly-x/EvilRole does not satisfy the intended scope, so the residual is pinned rather than remembered.
Whatever is chosen, note the house rule: a security condition must be compared to its expected value. "Starts with the right prefix" is a shape check, and a shape check admits every value of that shape.
Verification bar
Both directions. Assert that a path-bearing impostor ARN is refused and that the real deploy roles still match, or the fix will pass its test by refusing everything and break the deploy.
Surfaced during the review of LeanerCloud/cloud-commitments-cli#1818 (which closes LeanerCloud/cloud-commitments-cli#1723). Pre-existing, not introduced by that PR, which changed no
Resource,Conditionor ARN line. Filed separately rather than widening LeanerCloud/cloud-commitments-cli#1818's scope.What
terraform/environments/aws/ci-cd-permissions/policy_data.tfscopes several statements with a trailing-wildcard role ARN::61arn:aws:iam::*:role/cudly-*(withpolicy/cudly-*andinstance-profile/cudly-*):77arn:aws:iam::*:role/cudly-*, theiam:PassRolestatement, conditioned oniam:PassedToServiceIn an IAM resource ARN,
*matches any sequence of characters including/, and the IAM role path is part of the ARN. Soarn:aws:iam::123456789012:role/cudly-x/EvilRolesatisfiesarn:aws:iam::*:role/cudly-*. The prefix that is supposed to identify our roles can be carried in the path, which is a field the role's creator controls, rather than in the role name.The same shape applies to
arn:aws:iam::*:role/CUDly*inCrossAccountAssumeRoleCeiling. That one additionally has drift protection fromTestBoundaryMatchesCrossAccountRolePrefix, but the path-matching property is identical. Note this is a different concern from LeanerCloud/cloud-commitments-cli#1628 item 4 / LeanerCloud/cloud-commitments-cli#1636, which is about the missing per-tenant account condition onsts:AssumeRole, not about path-bearing ARNs.Why it matters
This is the classic IAM path wildcard bypass: an attacker satisfies a name-prefix guard by putting the required prefix in a segment they choose.
It is currently contained, and the containment is worth stating precisely so nobody over-reacts or under-reacts:
iam:CreateRole, which sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it cloud-commitments-cli#1705 / sec(iac/aws): gate deploy-role IAM writes on a permissions boundary cloud-commitments-cli#1722 gated on the target carrying the permissions boundary. A role created atcudly-x/…is therefore itself boundaried.cudly-*role created out of band, by hand or in the console, by someone who already has IAM privileges.PassRoleCeiling's own comment already admits this residual.So this is not a live escape today. It is a guard whose correctness depends entirely on a separate control staying in place, which is exactly the kind of coupling that breaks silently later.
Fix direction
Prefer a form that cannot be satisfied by a path segment. Options, roughly in order of preference:
arn:aws:iam::*:role/cudly-*and add aniam:ResourceTagor a path condition that pins the expected path, so the name prefix is not the only thing being checked.role/cudly/…) and match on the full, expected shape rather than a trailing wildcard.arn:aws:iam::123456789012:role/cudly-x/EvilRoledoes not satisfy the intended scope, so the residual is pinned rather than remembered.Whatever is chosen, note the house rule: a security condition must be compared to its expected value. "Starts with the right prefix" is a shape check, and a shape check admits every value of that shape.
Verification bar
Both directions. Assert that a path-bearing impostor ARN is refused and that the real deploy roles still match, or the fix will pass its test by refusing everything and break the deploy.