Skip to content

sec(iac/aws): require OIDC subject claim in aws-target CloudFormation - #1602

Merged
cristim merged 3 commits into
mainfrom
sec/1543-aws-oidc-subject-required
Jul 28, 2026
Merged

sec(iac/aws): require OIDC subject claim in aws-target CloudFormation#1602
cristim merged 3 commits into
mainfrom
sec/1543-aws-oidc-subject-required

Conversation

@cristim

@cristim cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member

Refs #1543. Follow-up for the remaining attack surface: #1640.

Scope. This PR closes the CloudFormation and Terraform bundles. It does not close the CLI bundle, so #1543 stays open until #1640 lands. See Attack surface below.

iac/federation/aws-target/cloudformation/template.yaml made the OIDC subject restriction optional: OIDCSubjectClaim defaulted to "", and a HasSubject condition selected a trust statement that omitted the <issuer>:sub condition entirely, gating only on <issuer>:aud. With OIDCAudience also left at its default, the audience collapsed to the literal sts.amazonaws.com.

Customers deploy this template into their own AWS accounts. With the documented accounts.google.com issuer and the default parameters, any Google service account anywhere could mint an ID token with audience=sts.amazonaws.com, present it to sts:AssumeRoleWithWebIdentity, and land in the customer account holding ec2:PurchaseReservedInstancesOffering, savingsplans:CreateSavingsPlan and rds:PurchaseReservedDBInstancesOffering. Those are irreversible multi-year spend commitments, reachable by anyone who could read the role ARN.

The Terraform sibling already refuses this (oidc_subject_claim has no default and validates against empty values); this brings the CloudFormation path to parity.

Changes

  • Drop the HasSubject condition and the subject-less trust statement, so the :sub condition is always present.
  • Make OIDCSubjectClaim required: no Default, MinLength: 1, and an AllowedPattern rejecting whitespace-only values and any * or $.
  • Apply the same $ / * rejection to OIDCAudience, and to the Terraform oidc_subject_claim and oidc_audience variables.
  • Correct two documentation errors (GCP subject format, and the false "leave empty to skip audience matching" claim).
  • Record the finding and the still-open bundle-generator gap in known_issues/13_iac_aws_target.md.

Why $ had to be rejected: the guard was fail-open

The first version of the AllowedPattern rejected whitespace and * but still admitted $. An adversarial review found that left the guard fail-open.

AssumeRolePolicyDocument is an IAM policy document (Version: 2012-10-17), and IAM expands ${...} policy variables inside Condition values, not only in resource ARNs. AWS documents ${accounts.google.com:sub} and ${accounts.google.com:aud} as valid web-identity policy variables, for exactly the accounts.google.com issuer this template documents. So an operator who sets:

OIDCSubjectClaim=${accounts.google.com:sub}

renders the condition:

"accounts.google.com:sub": "${accounts.google.com:sub}"

IAM expands that to the presented token's own sub claim. The condition compares every subject to itself: it is a tautology satisfied by every token the issuer can mint. That is the #1543 hole reconstructed through the very parameter added to close it. The value is reachable by copy-pasting a placeholder, by a templating layer that failed to interpolate, or by a generator emitting an unresolved variable.

Contrast with *, which StringEquals compares literally and which therefore only produces a dead role that nobody can assume. * fails closed; $ fails open. Both are now rejected at submission time.

Audience is hardened in the same pass because, with the subject now pinned, aud is the only other control on this trust policy.

Verified pattern behaviour

Final patterns, both a single anchored character class:

OIDCSubjectClaim  ^[^\s*$]+$
OIDCAudience   ^$|^[^\s*$]+$

Both were parsed back out of the committed YAML (not retyped) and checked as an anchored full match over 43 inputs, 0 mismatches. Every legitimate format passes; every whitespace, $ and * form is rejected.

Legitimate formats, all PASS both patterns (18 checked): GCP service-account numeric unique IDs (21- and 20-digit), Azure object-ID GUIDs, Azure api://<guid>, Kubernetes system:serviceaccount:ns:name, GitHub Actions repo:org/repo:ref:refs/heads/main and repo:org/repo:environment:production, GitLab project_path:group/project:ref_type:branch:ref:main, SPIFFE spiffe://cluster.local/ns/default/sa/my-app, Auth0 auth0|5f7c... and google-oauth2|1034..., Bitbucket {f7c8ec7c-...}, the GCP workload-identity-pool provider URL, sts.amazonaws.com, LDAP-style DNs, URNs, and a single character.

All REJECTED by both patterns (22 checked): " ", " ", tab, newline, leading and trailing whitespace, interior space, interior newline, interior tab, *, a*b, *abc, abc*, $, $$, abc$def, ${accounts.google.com:sub}, ${accounts.google.com:aud}, ${aws:userid}, ${aws:PrincipalTag/x}, x${accounts.google.com:sub}, ${accounts.google.com:sub}x.

Empty string: subject FAILS (required), audience PASSES (falls back to sts.amazonaws.com).

The simplified ^[^\s*$]+$ is provably identical to the three-branch form it replaces (^[^\s*$]$|^[^\s*$][^\s*$]*[^\s*$]$) once the middle class matches the edge classes. The verification asserts that equivalence across the whole corpus rather than assuming it, so the simplification is not a second behaviour change.

Interior whitespace was tightened, not left. Two independent reviews noted that the earlier middle class [^*$] still admitted a b and ab\ncd. Neither called it a finding, correctly: such a value carries no $ or *, so no policy-variable expansion; CloudFormation JSON-escapes it, so no injection; and no real sub/aud claim contains whitespace, so the result is a subject nothing equals, i.e. a dead role. Fail-closed. It is tightened here only because a push was happening anyway, and none of the 18 real formats above contains whitespace.

One recorded engine divergence, fail-closed. Java's \s is ASCII-only while Python's is Unicode-aware, so a value containing a non-breaking space (U+00A0) or em space (U+2003) is rejected by the local verification but would be accepted by CloudFormation's Java engine. This is not claimed as blocked. It remains fail-closed for the same reason as above: no $ or * means no policy-variable expansion, and no real claim contains those characters. This is a documentation-accuracy point, not a hole.

One uncited claim, flagged rather than dressed up. The parameter documentation now says the GCP service-account subject is a "numeric unique ID (typically 21 digits; Google documents it as a numeric string without guaranteeing a length)". The no-guaranteed-length part was not sourced against Google's published documentation by anyone in this review chain. The wording is deliberately weaker than the "21-digit" assertion it replaces, so it holds whether or not a length guarantee exists somewhere, but it should not be read as a verified citation.

Attack surface: closed vs. still open

Closed by this PR:

  • The checked-in CloudFormation template (iac/federation/aws-target/cloudformation/). The :sub condition is unconditional and the parameter cannot hold a tautology.
  • The checked-in Terraform module (iac/federation/aws-target/terraform/), which gains the same $ / * rejection on both oidc_subject_claim and oidc_audience.

Why Terraform is fixed in the same PR rather than deferred. The Terraform sibling had the identical fail-open: oidc_subject_claim validated only non-emptiness, and oidc_audience had no validation at all. The CloudFormation ConstraintDescription explicitly claims parity with it ("Mirrors the required oidc_subject_claim variable in the Terraform module"). Fixing only the CloudFormation path would leave the same tautology exploitable through Terraform while the docs assert the two are equivalent, which is worse than fixing either both or neither, because a reader is actively misled about coverage. That is the same "sibling instance" shape that already bit this PR once: the CLI bundle still ships the vulnerable policy, which is exactly why the trailer is now Refs rather than Closes. Fixing one instance of a defect class while a named-as-equivalent sibling keeps it is the pattern to avoid. Blast radius is contained: iac/federation/aws-target/ has no other open PR touching it.

Still open, tracked in #1640:

  • The CLI bundle is exploitable. internal/iacfiles/templates/aws-wif-cli.sh.tmpl:17 still treats OIDC_SUBJECT_CLAIM as optional, and its else branch (:57-70) creates the role with only the :aud condition and no :sub. format=cli is a first-class user-selectable download (internal/api/handler_federation.go:401,415), so a customer choosing the CLI bundle still gets exactly the role sec(iac): aws-target CloudFormation makes the OIDC sub restriction optional; the default trusts every identity from the issuer #1543 describes. This path never goes through CloudFormation, so nothing rejects it.
  • internal/iacfiles/templates/aws-cfn-deploy.sh.tmpl:34-38 does not forward OIDCSubjectClaim, so the CloudFormation onboarding flow is now broken (fail-closed) until the script is updated.
  • internal/iacfiles/templates/aws-wif.tfvars.tmpl:14-15 labels oidc_subject_claim Optional, contradicting the required Terraform variable (fail-closed, but misleading).

internal/ is owned by concurrent in-flight branches, so it is deliberately untouched here. The trailer is Refs #1543, not Closes, so merging this does not auto-close a p0/CRITICAL whose exploit is still reachable through the CLI bundle.

Breaking change

Stacks deployed with the blank default now fail to update until OIDCSubjectClaim is supplied. That is deliberate; the alternative is leaving an open trust policy in place. The rendered trust policy for stacks that already set a subject is byte-identical.

Existing deployments remain vulnerable until they redeploy with a subject claim set.

Second breaking surface: the audience and the Terraform variables. The OIDCAudience AllowedPattern was tightened in the same pass, and the Terraform module gained validations on oidc_subject_claim and oidc_audience. So an existing stack, or an existing .tfvars, carrying whitespace, $ or * in either value will now fail at change-set creation or at terraform plan rather than applying. Realistically no such deployment exists (none of the 18 real subject/audience formats contains any of those characters, and a $ value would have been a live tautology), but it is a second way this PR can break an existing deployment and it should not go unmentioned.

Verification

Gate Result
Both AllowedPatterns re-parsed from the committed YAML and checked against 43 inputs (18 legitimate formats, 22 rejections, empty-string asymmetry) exit 0, 0 mismatches
Simplified vs three-branch pattern equivalence proven over the full corpus identical on all 43
CFN-tag-aware YAML parse (trust policy pins both :aud and :sub; HasSubject gone; subject has no Default, MinLength: 1) exit 0
terraform validate / terraform fmt -check / tflint exit 0
Terraform validations exercised with terraform plan, both variables tautology, * and interior whitespace rejected; real GCP subject, system:serviceaccount:, sts.amazonaws.com and empty audience accepted
CI on the previous commit c945995d0 (all 4 runs) CI - Build & Test, pre-commit, AWS Sanity, Azure Sanity: all success
markdownlint (repo config) exit 0
pre-commit hooks over the commit range (markdownlint, terraform_fmt/validate/tflint, trailing-whitespace, end-of-file-fixer, git-secrets, detect-private-key) all Passed, exit 0

Do not read the CodeRabbit check on this commit either way. For c945995d0 it reports state=success with description="Review rate limited" (updated 19:05:00Z), while a genuine full-range CodeRabbit review did run and posted at 19:04:55Z. The status is unreliable in both directions here: green does not mean a review happened, and the "rate limited" text does not mean one did not. The truth lives only in the comment stream. This is the hazard documented in #1530.

cfn-lint cannot lint this template: it exits 2 with E0000 Unhashable type "{'Fn::Sub': '${OIDCIssuerHost}:aud'}". That is a known cfn-lint limitation with intrinsics in mapping-key position, it is pre-existing since bf19197ea (identical exit 2 on the parent commit's template), and it is why .pre-commit-config.yaml excludes iac/.*/cloudformation/.* from check-yaml. It is a tool limitation, not a template defect, and is not recorded as a passing gate. There is no cfn-lint or validate-template step anywhere in CI, so no automated check guards this file.

Provenance

The original fix was authored and pushed but its PR was never opened, because the authoring session hit an account-level limit immediately afterwards. Recovered from the pushed branch rather than re-implemented. The $ fail-open gap was found by a subsequent independent adversarial review of that commit.

Summary by CodeRabbit

  • New Features

    • Federation configuration now always requires a non-empty OIDC subject claim.
    • Empty OIDC audiences now default to sts.amazonaws.com instead of disabling audience matching.
    • Trust policies consistently enforce subject and audience restrictions.
  • Bug Fixes

    • Added validation to reject whitespace and unsafe characters in OIDC audience and subject values.
    • Improved protection against malformed or overly permissive trust policies.
  • Documentation

    • Updated known-issue tracking and documented a limitation affecting federation bundle generation.

The OIDCSubjectClaim parameter defaulted to "" and a HasSubject condition
selected a trust statement that omitted the <issuer>:sub condition
entirely, gating only on <issuer>:aud. With OIDCAudience also left at its
default the audience collapsed to the literal sts.amazonaws.com.

Customers deploy this template into their own AWS accounts. With the
documented accounts.google.com issuer and the default parameters, any
Google service account anywhere could mint an ID token with
audience=sts.amazonaws.com, present it to sts:AssumeRoleWithWebIdentity,
and land in the customer account holding
ec2:PurchaseReservedInstancesOffering, savingsplans:CreateSavingsPlan and
rds:PurchaseReservedDBInstancesOffering. Those are irreversible
multi-year spend commitments, reachable by anyone who could read the role
ARN.

The Terraform sibling already refuses this: oidc_subject_claim has no
default and carries a validation rejecting empty values. Bring the
CloudFormation path to parity.

- Drop the HasSubject condition and the subject-less trust statement, so
  the :sub condition is always present.
- Make OIDCSubjectClaim required: no Default, MinLength 1, and an
  AllowedPattern rejecting whitespace-only values and any '*'. The trust
  policy matches with StringEquals, so a '*' is compared literally and
  would silently produce a role nobody can assume; reject it loudly
  rather than deploy a dead role.
- Record the finding and the still-open bundle-generator gap in
  known_issues/13_iac_aws_target.md, and correct the stale "RESOLVED"
  note that described the conditional :sub as the fix.

BREAKING: stacks deployed with the blank default now fail to update until
OIDCSubjectClaim is supplied. That is deliberate; the alternative is
leaving an open trust policy in place. The rendered trust policy for
stacks that already set a subject is byte-identical.

Closes #1543
@cristim cristim added priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/s Hours type/security Security finding triaged Item has been triaged labels Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

CloudFormation now requires and validates OIDCSubjectClaim, and the trust policy always enforces :sub. CloudFormation and Terraform reject unsafe OIDC input characters. Known-issue tracking documents the generator gap and updated validation.

Changes

AWS OIDC trust hardening

Layer / File(s) Summary
Require and enforce OIDC subject
iac/federation/aws-target/cloudformation/template.yaml
OIDCSubjectClaim is required and validated; audience fallback and validation are tightened; and the trust policy always applies :sub while retaining conditional :aud handling.
Harden Terraform OIDC variables
iac/federation/aws-target/terraform/variables.tf
Audience and subject variables reject whitespace, $, and *, with updated IAM expansion guidance and validation messages.
Document enforcement and generator gap
known_issues/13_iac_aws_target.md
Audit status, required generator input, unconditional :sub enforcement, and tightened audience validation are documented.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • Issue 1543 — Directly addressed by making OIDCSubjectClaim mandatory and removing the conditional no-:sub trust-policy branch.
  • Issue 1640 — Related to the CloudFormation and Terraform hardening; the remaining generator gaps are documented here.
  • Issue 1615 — Related OIDC trust-policy security hardening in the same CloudFormation template.
  • Issue 1547 — Related AWS OIDC subject-claim drift fixed through mandatory input and enforced :sub conditions.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: making the OIDC subject claim required in the aws-target CloudFormation template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1543-aws-oidc-subject-required

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@iac/federation/aws-target/cloudformation/template.yaml`:
- Around line 55-70: Update the CloudFormation bundle generation flow in
handler_federation.go, including its input path, to accept and emit the
validated OIDC subject claim as the OIDCSubjectClaim override. Ensure generated
AWS WIF bundles always provide a non-empty, whitespace-trimmed, wildcard-free
subject claim, and do not add an empty or optional default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c1944ef7-acdf-494e-bb27-74916bcb93c4

📥 Commits

Reviewing files that changed from the base of the PR and between 3e9660d and 902eb83.

📒 Files selected for processing (2)
  • iac/federation/aws-target/cloudformation/template.yaml
  • known_issues/13_iac_aws_target.md

Comment thread iac/federation/aws-target/cloudformation/template.yaml Outdated
The AllowedPattern added for OIDCSubjectClaim rejected whitespace and '*'
but still admitted '$'. That left the guard fail-open.

AssumeRolePolicyDocument is an IAM policy document (Version 2012-10-17),
and IAM expands ${...} policy variables inside Condition VALUES, not just
in Resource ARNs. AWS documents ${accounts.google.com:sub} and
${accounts.google.com:aud} as valid web-identity policy variables for
exactly the accounts.google.com issuer this template documents. So an
operator who sets:

  OIDCSubjectClaim=${accounts.google.com:sub}

renders the condition:

  "accounts.google.com:sub": "${accounts.google.com:sub}"

which IAM expands to the presented token's own sub claim. The condition
compares every subject to itself, is satisfied by every token the issuer
can mint, and reconstructs the #1543 hole through the very parameter
added to close it. The value is reachable by copy-pasting a placeholder,
by a templating layer that failed to interpolate, or by a generator
emitting an unresolved variable. Unlike '*', which StringEquals compares
literally and which therefore only yields a dead role (fail-closed), this
one fails OPEN.

Reject '$' in both parameters:

  OIDCSubjectClaim  ^[^\s*$]$|^[^\s*$][^*$]*[^\s*$]$
  OIDCAudience   ^$|^[^\s*$]$|^[^\s*$][^*$]*[^\s*$]$

No legitimate subject or audience format contains '$': GCP 21-digit
numeric unique IDs, Azure object-ID GUIDs, system:serviceaccount:<ns>:<name>,
repo:org/repo:ref:refs/heads/main, api://<client_id>, and the GCP
workloadIdentityPools audience URL all pass. Audience is hardened in the
same pass because it is now the only other control on this trust policy.

The Terraform sibling had the identical hole: oidc_subject_claim only
validated non-emptiness, and oidc_audience had no validation at all. Both
now reject '$' and '*' too, so the two bundles stay at parity.

Also correct two documentation errors this PR is responsible for:

- The GCP subject format was given as system:serviceaccount:<project>:
  <sa-email>. That is the Kubernetes subject format, belongs to a cluster
  OIDC issuer rather than accounts.google.com, and its field names are
  wrong for it. For accounts.google.com the sub claim is the service
  account's 21-digit numeric unique ID.
- OIDCAudience claimed "leave empty to skip audience matching". Empty does
  not skip anything: the condition and the OIDC provider ClientIdList both
  pin to the literal sts.amazonaws.com.

Scope: this closes the CloudFormation and Terraform bundles. The CLI
bundle remains open. internal/iacfiles/templates/aws-wif-cli.sh.tmpl still
treats OIDC_SUBJECT_CLAIM as optional and its else branch creates the role
with only the :aud condition, and format=cli is a user-selectable download
in internal/api/handler_federation.go. internal/ is owned by concurrent
branches, so that path plus the aws-cfn-deploy.sh.tmpl and
aws-wif.tfvars.tmpl gaps are filed as #1640 and recorded in
known_issues/13_iac_aws_target.md.

Verification: the two AllowedPattern values were parsed back out of the
committed YAML and checked against 24 inputs covering every legitimate
subject and audience format plus every '$' and '*' form; terraform
validate, fmt, tflint and markdownlint all clean; the new Terraform
validations were exercised with terraform plan and reject the tautology
while accepting a real GCP subject. cfn-lint cannot lint this template
(exit 2, E0000 unhashable key on the Fn::Sub condition keys, pre-existing
since bf19197 and the reason check-yaml excludes this path).

Refs #1543
Refs #1640
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed c945995d0 addressing an independent adversarial review of the previous commit.

The AllowedPattern guard was fail-open. It rejected whitespace and *, but still admitted $. AssumeRolePolicyDocument is an IAM policy document, and IAM expands ${...} policy variables inside Condition values. ${accounts.google.com:sub} is a documented web-identity policy variable for exactly the accounts.google.com issuer this template targets, so OIDCSubjectClaim=${accounts.google.com:sub} renders "accounts.google.com:sub": "${accounts.google.com:sub}", a tautology IAM satisfies for every subject. That is the #1543 hole reconstructed through the parameter added to close it. * by contrast is compared literally by StringEquals and only yields a dead role, so it fails closed; $ fails open.

Changes:

  • Reject $ as well as * in OIDCSubjectClaim and OIDCAudience, and in the Terraform oidc_subject_claim / oidc_audience variables (audience is now the only other control on this trust policy, and the Terraform sibling had the identical hole).
  • Closes #1543 -> Refs #1543. The CLI bundle (aws-wif-cli.sh.tmpl) still creates a subject-less role and format=cli is a user-selectable download, so the exploit remains reachable. Filed as sec(iac): federation bundle generator still emits a subject-less AWS trust policy (CLI bundle exploitable) #1640; internal/ is owned by concurrent branches and deliberately untouched here. The PR body now states which surface is closed and which is not.
  • Corrected two doc errors: the GCP subject is the service account's 21-digit numeric unique ID, not the Kubernetes system:serviceaccount:... form; and an empty OIDCAudience does not skip audience matching, it pins to the literal sts.amazonaws.com.
  • Expanded the known_issues entry to also list aws-cfn-deploy.sh.tmpl:34-38 and aws-wif.tfvars.tmpl:14-15.

Both patterns were re-parsed out of the committed YAML and checked against 24 inputs (table in the PR body); every legitimate subject/audience format passes and every $/* form is rejected. terraform validate/fmt/tflint, markdownlint and the relevant pre-commit hooks are all exit 0.

Note for review: cfn-lint cannot lint this template. It exits 2 with E0000 Unhashable type "{'Fn::Sub': '${OIDCIssuerHost}:aud'}", a known limitation with intrinsics in mapping-key position, pre-existing since bf19197ea and identical on the parent commit. It is why .pre-commit-config.yaml excludes iac/.*/cloudformation/.* from check-yaml.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR metadata ==='
gh pr view 1602 --json number,title,body,baseRefName,headRefName,commits --jq '{number,title,baseRefName,headRefName,commits:[.commits[]|{oid,messageHeadline}]}'

echo '=== Changed files ==='
git diff --name-status HEAD^ HEAD

echo '=== Commit diff ==='
git diff --find-renames --unified=80 HEAD^ HEAD -- \
  iac/federation/aws-target/cloudformation/template.yaml \
  known_issues/13_iac_aws_target.md \
  ':(glob)**/*.tf' \
  ':(glob)**/*.tmpl' \
  .pre-commit-config.yaml

echo '=== Candidate source files ==='
fd -t f -a . | rg '(template\.yaml|variables\.tf|aws-wif-cli\.sh\.tmpl|aws-cfn-deploy\.sh\.tmpl|aws-wif\.tfvars\.tmpl|13_iac_aws_target\.md|\.pre-commit-config\.yaml)$'

echo '=== OIDC parameter/variable and trust-policy references ==='
rg -n -C 8 --glob '*.yaml' --glob '*.yml' --glob '*.tf' --glob '*.tmpl' --glob '*.md' \
  '(OIDCSubjectClaim|OIDCAudience|oidc_subject_claim|oidc_audience|AssumeRoleWithWebIdentity|AssumeRolePolicyDocument|accounts\.google\.com:sub|aws-wif-cli)' \
  iac known_issues 2>/dev/null || true

echo '=== Related issue status ==='
gh issue view 1543 --json number,state,title,url,body --jq '{number,state,title,url,body}'
gh issue view 1640 --json number,state,title,url,body --jq '{number,state,title,url,body}'

Length of output: 88682


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base="$(git merge-base origin/main HEAD 2>/dev/null || git merge-base main HEAD)"
echo "=== Merge base: $base ==="

echo '=== Aggregate PR files ==='
git diff --name-status "$base" HEAD

echo '=== Aggregate PR diff ==='
git diff --find-renames --check "$base" HEAD
git diff --find-renames --unified=35 "$base" HEAD -- \
  iac/federation/aws-target/cloudformation/template.yaml \
  iac/federation/aws-target/terraform/main.tf \
  iac/federation/aws-target/terraform/variables.tf \
  known_issues/13_iac_aws_target.md

echo '=== CloudFormation trust-policy and parameter context ==='
nl -ba iac/federation/aws-target/cloudformation/template.yaml | sed -n '1,220p'

echo '=== Terraform module outline and trust-policy context ==='
ast-grep outline iac/federation/aws-target/terraform --items all --type variable,resource,locals
nl -ba iac/federation/aws-target/terraform/main.tf | sed -n '1,180p'
nl -ba iac/federation/aws-target/terraform/variables.tf | sed -n '1,100p'

echo '=== Current generated bundle interfaces (out-of-scope gap verification) ==='
nl -ba internal/iacfiles/templates/aws-wif-cli.sh.tmpl | sed -n '1,115p'
nl -ba internal/iacfiles/templates/aws-cfn-deploy.sh.tmpl | sed -n '1,90p'
nl -ba internal/iacfiles/templates/aws-wif.tfvars.tmpl | sed -n '1,70p'
rg -n -C 10 'func buildCFParamsJSON|buildCFParamsJSON\s*\(' internal/api/handler_federation.go

echo '=== Parameter regex test matrix using Python re (structural sanity check) ==='
python3 - <<'PY'
import re
patterns = {
    "audience": r"^$|^[^\s*$]$|^[^\s*$][^*$]*[^\s*$]$",
    "subject": r"^[^\s*$]$|^[^\s*$][^*$]*[^\s*$]$",
}
values = ["", "x", "abc", " abc", "abc ", " ", "\t", "${accounts.google.com:sub}",
          "${accounts.google.com:aud}", "foo$bar", "foo*bar", "*", "$", "a\nb"]
for name, pattern in patterns.items():
    print(name, [(repr(v), bool(re.fullmatch(pattern, v))) for v in values])
PY

Length of output: 32725


🏁 Script executed:

#!/bin/bash
set -euo pipefail

show_range() {
  local file="$1" start="$2" end="$3"
  echo "=== ${file}:${start}-${end} ==="
  awk -v start="$start" -v end="$end" 'NR >= start && NR <= end { printf "%6d  %s\n", NR, $0 }' "$file"
}

show_range iac/federation/aws-target/cloudformation/template.yaml 25 210
show_range iac/federation/aws-target/terraform/main.tf 1 165
show_range iac/federation/aws-target/terraform/variables.tf 15 75
show_range internal/iacfiles/templates/aws-wif-cli.sh.tmpl 1 115
show_range internal/iacfiles/templates/aws-cfn-deploy.sh.tmpl 1 90
show_range internal/iacfiles/templates/aws-wif.tfvars.tmpl 1 70

echo '=== buildCFParamsJSON context ==='
rg -n -C 14 'func buildCFParamsJSON|buildCFParamsJSON\s*\(' internal/api/handler_federation.go

echo '=== Regex matrix ==='
python3 - <<'PY'
import re
patterns = {
    "audience": r"^$|^[^\s*$]$|^[^\s*$][^*$]*[^\s*$]$",
    "subject": r"^[^\s*$]$|^[^\s*$][^*$]*[^\s*$]$",
}
values = ["", "x", "abc", " abc", "abc ", " ", "\t", "${accounts.google.com:sub}",
          "${accounts.google.com:aud}", "foo$bar", "foo*bar", "*", "$", "a\nb"]
for name, pattern in patterns.items():
    print(name)
    for value in values:
        print(f"  {value!r}: {'accept' if re.fullmatch(pattern, value) else 'reject'}")
PY

echo '=== Existing PR review comments ==='
gh api repos/LeanerCloud/CUDly/pulls/1602/comments --paginate \
  --jq '.[] | {path,line,side,body,user:.user.login}' || true

Length of output: 35975


🤖 web_search executed:

AWS IAM documentation: policy variables in Condition element values, including web identity federation context keys such as accounts.google.com:sub and accounts.google.com:aud

Length of output: 21047


@cristim Reviewed the full PR range, including the CloudFormation trust policy, Terraform parity, regex behavior, and the documented generator gap.

No new actionable findings. The required, unconditional :sub condition is correctly enforced, and rejecting $ closes the IAM policy-variable tautology route for both subject and audience values. The remaining bundle-generator exposure is accurately documented and tracked in #1640, so changing Closes #1543 to `Refs `#1543 is appropriate.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Note for anyone reading this PR's checks: the CodeRabbit status is not a reliable signal here

On commit c945995d0 the commit status reads:

context:     CodeRabbit
state:       success
description: Review rate limited
updated_at:  2026-07-28T19:05:00Z

A genuine full-range CodeRabbit review did run on this commit and posted at 2026-07-28T19:04:55Z, five seconds before that status was written. CodeRabbit executed its own analysis scripts, independently reproduced the regex acceptance matrix in Python, web-searched the AWS documentation on policy variables in Condition values, and returned "No new actionable findings."

So the status misleads in both directions on this commit:

The authoritative record is the comment stream, not the check. Please do not conclude from the check alone that this PR was or was not reviewed. This is the second time today this status has misled a reader.

Related: #1530 recommends never adding the CodeRabbit context to required checks for exactly this reason.

Follow-up polish on the policy-variable guard. Two independent reviews
converged on the same residual: the middle character class [^*$] still
permitted interior whitespace and newlines, so "a b" and "ab\ncd" passed.

That was never a vulnerability. Such a value contains no '$' or '*', so no
IAM policy-variable expansion occurs, and CloudFormation JSON-escapes it
into the policy document, so no injection occurs. No real token carries a
sub or aud claim with embedded whitespace, which means the result is simply
a subject nothing equals: a dead role, fail-closed. Both reviews correctly
declined to call it a finding. It is tightened here only because a push was
happening anyway, and it removes the last open question either review
raised at zero cost.

Both patterns collapse to a single anchored character class:

  OIDCSubjectClaim  ^[^\s*$]+$
  OIDCAudience   ^$|^[^\s*$]+$

The three-branch form they replace is exactly equivalent once the middle
class matches the edge classes, so this is a simplification rather than a
second behaviour change; the verification asserts that equivalence over the
full input corpus rather than assuming it.

Checked against 18 real subject and audience formats, none of which
contains whitespace: GCP numeric unique IDs, Azure object-ID GUIDs and
api:// audiences, Kubernetes system:serviceaccount:, GitHub Actions repo:
and environment forms, GitLab project_path:, SPIFFE IDs, Auth0 pipe and
federated forms, Bitbucket braced UUIDs, the GCP workload-identity-pool
provider URL, LDAP-style DNs, URNs and sts.amazonaws.com.

The Terraform validations are widened in step, from [*$] to [\s*$], so the
module stays equivalent to the AllowedPattern that the template's
ConstraintDescription claims to mirror. An empty audience still passes
without an explicit empty-string clause, because it contains none of the
rejected characters; that is stated in the comment rather than encoded as a
redundant condition.

Also corrected, all three found by review:

- The GCP subject was documented as a "21-digit" numeric unique ID. Google
  documents uniqueId as a numeric string with no guaranteed length, so an
  operator holding a 20-digit ID could conclude they had the wrong value.
  Now "numeric unique ID (typically 21 digits)". Same class of defect as
  the Kubernetes-format error corrected in the previous commit: a
  precise-sounding but slightly wrong instruction on a required security
  parameter.

- known_issues/13_iac_aws_target.md stated the tfvars path is fail-closed
  because "Terraform's variable validation rejects the omission". With
  oidc_subject_claim commented out there is no value for a validation block
  to inspect, so the validations never fire. What makes it fail-closed is
  the absent default, which makes Terraform prompt interactively or
  hard-error under -input=false. The security property holds; the stated
  mechanism did not.

- Refreshed the two stale AllowedPattern citations in the same file.

Verification: both patterns were re-parsed out of the committed YAML and
checked over 43 inputs (18 legitimate formats, 22 rejections, plus the
empty-string asymmetry), 0 mismatches; the simplified and three-branch
forms proved identical across that corpus; terraform validate, fmt, tflint
and markdownlint all exit 0; the widened Terraform validations were
exercised with terraform plan and now reject the tautology, '*' and
interior whitespace for BOTH variables while accepting real subjects and an
empty audience. The audience validation was caught still un-widened by that
plan run rather than by inspection.

Known engine divergence, fail-closed and recorded rather than fixed: Java's
\s is ASCII-only while Python's is Unicode-aware, so a value containing a
non-breaking or em space is rejected by the local verification but would be
accepted by CloudFormation. It carries no '$' or '*', so it still cannot
expand a policy variable, and it still matches no real claim.

Refs #1543
Refs #1640
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed b56556447. Final polish after a second independent adversarial review; no change to the security property established in the previous commit.

Interior whitespace tightened. Both reviews independently noted the middle class [^*$] still admitted a b and ab\ncd, and both correctly declined to call it a finding: such a value carries no $ or *, so no IAM policy-variable expansion; CloudFormation JSON-escapes it, so no injection; and no real sub/aud claim contains whitespace, so it yields a subject nothing equals, i.e. a dead role. Fail-closed. Tightened anyway since a push was happening. Both patterns now collapse to a single anchored class:

OIDCSubjectClaim  ^[^\s*$]+$
OIDCAudience   ^$|^[^\s*$]+$

That simplified form is provably identical to the three-branch form it replaces once the middle class matches the edge classes; the verification asserts the equivalence over the full corpus rather than assuming it. Re-parsed out of the committed YAML and checked over 43 inputs: 18 real subject/audience formats (GCP numeric IDs, Azure GUIDs and api://, Kubernetes, GitHub, GitLab, SPIFFE, Auth0, Bitbucket, GCP WIF URL, LDAP DN, URN) all PASS; 22 rejection cases including interior space/tab/newline all FAIL; empty-string asymmetry correct. 0 mismatches.

The Terraform validations widened from [*$] to [\s*$] in step, so the module stays equivalent to the AllowedPattern the template's ConstraintDescription claims to mirror. Worth noting: the terraform plan run is what caught that I had widened only the subject validation and left the audience one behind. Inspection had not.

Three review findings also addressed:

  • The GCP subject was documented as a "21-digit" numeric unique ID. Google documents uniqueId as a numeric string with no guaranteed length, so an operator holding a 20-digit ID could conclude they had the wrong value. Now "numeric unique ID (typically 21 digits)".
  • known_issues/13_iac_aws_target.md claimed the tfvars path is fail-closed because "Terraform's variable validation rejects the omission". With oidc_subject_claim commented out there is no value for a validation block to inspect, so the validations never fire. What actually makes it fail-closed is the absent default, which makes Terraform prompt interactively or hard-error under -input=false. Corrected; the security property held, the stated mechanism did not.
  • The PR body's Breaking-change section covered only OIDCSubjectClaim. It now also states the second breaking surface: the tightened audience pattern and the two Terraform validations mean an existing stack or .tfvars carrying whitespace/$/* in either value will now fail rather than apply.

One divergence recorded rather than fixed: Java's \s is ASCII-only while Python's is Unicode-aware, so a non-breaking or em space is rejected by the local verification but would be accepted by CloudFormation. Not claimed as blocked. Fail-closed for the same reason as above.

Gates: regex verification exit 0, structural YAML parse exit 0, terraform validate/fmt/tflint exit 0, markdownlint exit 0, 9 pre-commit hooks over the commit range all Passed/0. All 4 CI runs on the previous commit c945995d0 completed success.

Reminder for anyone reading the checks on this PR: the CodeRabbit commit status is unreliable in both directions here, see the earlier comment and #1530.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim: Reviewing the latest changes, including the simplified validation patterns, CloudFormation/Terraform parity, and the documentation corrections.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1602

Timestamp: 2026-07-28T19:25:18.745Z
Learning: In `iac/federation/aws-target`, `OIDCSubjectClaim` and `OIDCAudience` must reject `$`, `*`, and ASCII whitespace because IAM policy variables can expand `${...}` within trust-policy condition values. The CloudFormation template uses Java regular-expression semantics, where `\s` is ASCII-oriented; Python-based validation may additionally reject some Unicode whitespace. Those accepted Unicode-whitespace values are fail-closed because they do not enable policy-variable expansion and do not match real OIDC claims.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
iac/federation/aws-target/cloudformation/template.yaml (1)

66-93: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Wire the required subject through the bundle generators.

OIDCSubjectClaim is now mandatory, but the supplied generator and deployment paths still omit it. Generated CloudFormation bundles will fail with Parameters: [OIDCSubjectClaim] must have values until the validated subject is added to the generation input and every CloudFormation override path; do not restore an empty default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@iac/federation/aws-target/cloudformation/template.yaml` around lines 66 - 93,
Propagate the validated OIDC subject through all bundle-generation and
deployment paths: update the generator input and every CloudFormation
parameter-override path to supply OIDCSubjectClaim. Trace the existing subject
value from validation through the relevant generator/deployment functions and
preserve it unchanged; do not reintroduce an empty default or omit the parameter
in any path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@iac/federation/aws-target/cloudformation/template.yaml`:
- Around line 66-93: Propagate the validated OIDC subject through all
bundle-generation and deployment paths: update the generator input and every
CloudFormation parameter-override path to supply OIDCSubjectClaim. Trace the
existing subject value from validation through the relevant generator/deployment
functions and preserve it unchanged; do not reintroduce an empty default or omit
the parameter in any path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d5b8ccdb-041b-46d6-a4e9-5a365f2d2bbc

📥 Commits

Reviewing files that changed from the base of the PR and between 902eb83 and b565564.

📒 Files selected for processing (3)
  • iac/federation/aws-target/cloudformation/template.yaml
  • iac/federation/aws-target/terraform/variables.tf
  • known_issues/13_iac_aws_target.md

@cristim
cristim merged commit 3e0400b into main Jul 28, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant