Repository navigation
sec(iac): GCP WIF Terraform bypass - IAM user path defeats contains() role check, plus pool-wide grant #1667
Description
Severity: CRITICAL. Like #1544, this is customer-facing: iac/embed.go:9 embeds federation, and internal/api/handler_federation.go:641-655 returns federation/gcp-target as the default bundle for target=gcp, so the zip customers download ships main.tf with a pre-filled tfvars. Same endpoint, same customers, same defect class as #1544 - and more exploitable than the bug #1544 reported, because this path has a working bypass rather than just a missing second gate.
#1544's framing said "the shell path lacks the guard its Terraform sibling enforces". That premise is wrong, and PR #1651 disproves it. The Terraform path is the worse of the two.
(a) Live bypass: raw assertion.arn + substring test - HEADLINE
iac/federation/gcp-target/terraform/main.tf:102 maps the attribute raw:
"attribute.aws_role" = "assertion.arn"and :109 substring-tests it:
"attribute.aws_role.contains('assumed-role/${var.aws_role_name}/')"With aws_role_name = "CUDly-Execution", an attacker who has only iam:CreateUser in the customer's AWS account:
- Creates an IAM user with path
/assumed-role/CUDly-Execution/. AWS's documented path regex is(/)|(/[!-~]+/)- every character here is printable ASCII, so the path is legal. sts:GetCallerIdentityreturnsarn:aws:iam::<acct>:user/assumed-role/CUDly-Execution/evil.- The provider's
account_idcheck passes (same account).contains('assumed-role/CUDly-Execution/')is true - the crafted path supplies the substring the test looks for. - The pool-wide binding in (b) matches every identity in the pool.
Result: full impersonation of the CUDly GCP service account, which holds compute.commitments.create/update. iam:CreateUser escalated into cross-cloud GCP spend authority.
IAM role paths do not work, because STS drops the path from assumed-role ARNs. It is specifically the IAM user ARN, which retains its path, that defeats the substring test.
The shell script fixed in #1651 is immune, and the reason is the argument for the fix shape: it normalises the assertion down to the role ARN and compares with ==, so a user ARN never equals arn:aws:sts::<acct>:assumed-role/<role> regardless of its path.
(b) Pool-wide impersonation grant
main.tf:162:
member = ... "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.cudly.name}/*"Byte-identical in shape to the grant removed in #1651. Its inline comment - "wildcard principalSet is intentional; session ARNs include variable session names so exact-match principalSet cannot work" - is precisely the claim #1651 disproves.
Worth noting how far the two customer-facing paths have now diverged: the pool_wide_grants() scan added to setup-gcp-wif.sh in #1651 flags a Terraform-created grant as legacy and refuses to report success. A customer who onboards with Terraform and later runs the shell script is told, correctly, that their setup is unsafe.
(c) No validation on the CEL-interpolated variables
variables.tf:57-67: neither aws_role_name nor oidc_subject has a validation block, and both are interpolated into a CEL string literal. aws_role_name = "x') || true || ('" renders an always-true condition. This is the CEL break-out that validate_principal_value() exists to stop on the shell side.
(d) Stale "Recommended" wording
internal/iacfiles/templates/gcp-wif.tfvars.tmpl ships aws_role_name commented out and labelled "Recommended", while main.tf:139-142 requires it. A downloaded bundle fails terraform apply. Fails closed, but the wording misleads.
Fix direction
Mirror PR #1651:
"attribute.aws_role" = "assertion.arn.contains('assumed-role') ? assertion.arn.extract('{account_arn}assumed-role/') + 'assumed-role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn"
attribute_condition = "attribute.aws_role == 'arn:aws:sts::${var.aws_account_id}:assumed-role/${var.aws_role_name}'"
member = "principalSet://iam.googleapis.com/${pool.name}/attribute.aws_role/arn:aws:sts::${var.aws_account_id}:assumed-role/${var.aws_role_name}"plus validation blocks on both string vars rejecting ', ", \, backtick, * and $, and a fix to (d).
This changes attribute_mapping on live deployments, which existing customers federate against, so it needs its own migration note rather than a silent edit.
Related: #1544 (same defect class, shell path, fixed in #1651), #1661 (adjacent gaps in the served CLI template).
Activity
fix(iac/gcp): drop attribute.account so both WIF onboarding paths agree
sec(iac/gcp): pin WIF trust to the exact role ARN and narrow the SA g…
fix(iac/gcp): drop attribute.account so both WIF onboarding paths agree
cristim commentedon Aug 3, 2026
Closed by PR #1675 — verification record
Auto-closed by the
Closeskeyword; recording what was verified, since the merge rested on this rather than on CI.Note on the CodeRabbit signal: #1675's
CodeRabbitstatus readsuccesswhile itsdescriptionsaid "Review rate limited" — it never actually reviewed this p0 customer-facing PR, which presented as "0 unresolved threads". The gate here was an independent adversarial review.The fix is exact, and no bypass was found. The condition is
==against a normalised value. For it to match, the raw ARN must literally begin witharn:aws:sts::<pinned acct>:assumed-role/— and theiamvsstsservice segment is not attacker-controllable at any path depth. So this issue's exploit (an IAM user at path/assumed-role/CUDly-Execution/) normalises toarn:aws:iam::<acct>:user/assumed-role/CUDly-Executionand is rejected. A longer sibling role name (CUDly-Execution-Admin) is rejected by==.Non-obvious point worth preserving: Google documents two AWS mapping variants, and the simpler
extract('assumed-role/{role_name}/')form would still be vulnerable to this exact attack. The fix picked the full-ARN ternary variant.Both halves landed. The pool-wide
principalSet://.../*binding was genuinely narrowed to the documented exact-attribute-value form, not just the condition tightened. That also resolved an open question the PR had flagged: Google's docs show the ARN unencoded inside the identifier, so no URL-encoding is needed.Migration is a genuine in-place update, confirmed from provider source at three versions rather than registry prose:
attribute_mappingandattribute_conditionhave no ForceNew and both enter the updateMask. Not a breaking change.Tests were mutation-tested: four distinct mutations of
main.tfare each caught by a distinct test.Known residue, not blocking
depends_onandcreate_before_destroyorder the API calls but cannot order GCP's eventual consistency, so an existing customer re-applying may see a transient federation gap of minutes. Self-healing; no Terraform-side fix exists. A two-step-targetapply removes the window entirely and is worth documenting.Two deliberate narrowings: the pinned ARN hardcodes
arn:aws:, so GovCloud/China callers now fail closed; and the account is pinned in the condition (already enforced independently by the provider'saws { account_id }block).Cross-PR interaction caught before shipping
The Terraform module mapped a third attribute (
attribute.account) thatsetup-gcp-wif.shdoes not. Once #1673 added exact-set mapping validation, that would have hard-died on every Terraform-onboarded customer — with a wrong verdict and a destructive suggested remedy (delete a live provider). Resolved by dropping the unreferenced attribute; the two mapping sets are now byte-identical, verified bycmp.