From 8870af5fb27f174540154d1103330ddd4405866a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 29 Jul 2026 11:19:25 +0200 Subject: [PATCH 1/2] fix(arm): validate attribute mapping on GCP WIF provider reuse The provider-reuse path in setup-gcp-wif.sh compared an existing provider's attributeCondition against the expected value but never checked attributeMapping. The condition is only meaningful relative to the mapping that feeds it: a pre-existing provider whose condition is byte-identical to expected but whose mapping derives attribute.aws_role from a caller-controlled ARN path segment instead of the real assumed-role name lets an attacker who can create an IAM user with a crafted path impersonate the trusted role, while the script reports success. Extract the AWS and OIDC attribute mappings into EXPECTED_MAPPING (previously duplicated inline at both create-provider call sites) and compare it against the existing provider's mapping on reuse, failing the same way the condition check does on mismatch. gcloud's `value(...)` printer renders attributeMapping as semicolon-joined, key-sorted pairs, not the comma-joined form the --attribute-mapping flag takes as input, so a raw string comparison would false-positive on every correctly configured provider. Added normalize_mapping() to parse both sides into sorted key/value pairs before comparing. Also records that the AWS session-ARN 127-byte budget relies on the role/subject charset staying ASCII (chars == bytes). --- arm/CUDly-CrossSubscription/setup-gcp-wif.sh | 64 +++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/arm/CUDly-CrossSubscription/setup-gcp-wif.sh b/arm/CUDly-CrossSubscription/setup-gcp-wif.sh index 5fd0c8eb0..1a0657af4 100755 --- a/arm/CUDly-CrossSubscription/setup-gcp-wif.sh +++ b/arm/CUDly-CrossSubscription/setup-gcp-wif.sh @@ -126,7 +126,19 @@ if [[ "$PROVIDER_TYPE" == "aws" ]]; then # token exchange is refused rather than admitted on a partition mismatch. AWS_ROLE_ARN="arn:aws:sts::${AWS_ACCOUNT_ID}:assumed-role/${AWS_ROLE_NAME}" EXPECTED_CONDITION="attribute.aws_role == '${AWS_ROLE_ARN}'" - # google.subject is the full session ARN here and GCP caps it at 127 chars. + # attribute.aws_role normalises the session ARN + # (arn:aws:sts:::assumed-role//) down to the role ARN, + # so both EXPECTED_CONDITION above and the IAM grant below can match it + # exactly instead of relying on a substring test. Kept in a variable (not + # inlined at the create-provider call below) so the reuse path can validate + # an existing provider against the exact same expression instead of a + # second hand-copied literal that could silently drift from this one. + EXPECTED_MAPPING="google.subject=assertion.arn,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" + # google.subject is the full session ARN here and GCP caps it at 127 bytes, + # not characters (127 chars is used below only because both charset + # regexes constraining this value -- --aws-role-name above and + # --oidc-subject below -- are ASCII-only, so chars == bytes; this budget + # would silently under/over-count if that charset were ever widened). # "arn:aws:sts::<12 digits>:assumed-role/" is 39 characters, and the session # name is preceded by a "/", so the role name leaves 127 - 39 - len(role) - 1 # for a session name that AWS allows to reach 64. @@ -151,6 +163,7 @@ else # rejected at token-exchange time, long after this script reported success. [[ ${#OIDC_SUBJECT} -le 127 ]] || die "--oidc-subject must be at most 127 characters (google.subject limit); got ${#OIDC_SUBJECT}" EXPECTED_CONDITION="google.subject == '${OIDC_SUBJECT}'" + EXPECTED_MAPPING="google.subject=assertion.sub" fi PROJECT_NUMBER=$(gcloud projects describe "$PROJECT" --format='value(projectNumber)') @@ -192,21 +205,34 @@ echo " provider in pool '${POOL_ID}' that maps the same attribute value wou echo " also satisfy the grant below. Keep this pool dedicated to CUDly" >&2 echo " (--pool-id) unless you intend to share it." >&2 +# gcloud's `value(...)` printer renders a map field (attributeMapping) as its +# entries sorted by key and delimiter-joined with ';' by CsvPrinter._AddRecord +# (the same code path ValuePrinter inherits from), not the comma-joined +# "key=value,key=value" form the --attribute-mapping flag takes as input. +# Comparing the raw describe output against EXPECTED_MAPPING as strings would +# therefore report every correctly-configured provider as mismatched. This +# splits each side on its own pair separator, re-sorts, and compares parsed +# key/value pairs so neither the input-vs-output shape difference nor a +# hypothetical future change to gcloud's own key ordering can produce a false +# positive; a mapping that actually differs still compares unequal. +normalize_mapping() { + local mapping="$1" pair_sep="$2" + local -a pairs + IFS="$pair_sep" read -ra pairs <<<"$mapping" + printf '%s\n' "${pairs[@]}" | sort +} + # ── Idempotent provider creation ─────────────────────────────────────────────── if ! gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \ --project="$PROJECT" --location=global \ --workload-identity-pool="$POOL_ID" &>/dev/null; then echo "Creating ${PROVIDER_TYPE} provider '${PROVIDER_ID}'..." if [[ "$PROVIDER_TYPE" == "aws" ]]; then - # attribute.aws_role normalises the session ARN - # (arn:aws:sts:::assumed-role//) down to the role ARN, - # so both the condition below and the IAM grant can match it exactly instead - # of relying on a substring test. gcloud iam workload-identity-pools providers create-aws "$PROVIDER_ID" \ --project="$PROJECT" --location=global \ --workload-identity-pool="$POOL_ID" \ --account-id="$AWS_ACCOUNT_ID" \ - --attribute-mapping="google.subject=assertion.arn,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-mapping="$EXPECTED_MAPPING" \ --attribute-condition="$EXPECTED_CONDITION" \ --quiet else @@ -218,7 +244,7 @@ if ! gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \ --project="$PROJECT" --location=global \ --workload-identity-pool="$POOL_ID" \ --issuer-uri="$ISSUER_URI" \ - --attribute-mapping="google.subject=assertion.sub" \ + --attribute-mapping="$EXPECTED_MAPPING" \ --attribute-condition="$EXPECTED_CONDITION" \ --quiet fi @@ -256,7 +282,29 @@ A condition that is merely non-empty is not a restriction: 'true' and Delete the provider and re-run: ${DELETE_HINT}" fi - echo "Reusing existing provider '${PROVIDER_ID}' (attribute condition matches: ${EXISTING_CONDITION})" + # The condition alone does not decide who gets in: it is evaluated against + # whatever value the mapping produces, so a provider can carry the exact + # expected condition while a different mapping feeds it a normalised value + # derived from an identity this script never authorised (e.g. one where + # attribute.aws_role is built from a caller-controlled ARN path segment + # rather than the actual assumed-role name). Validating the condition + # without also validating the mapping only checks half of that property. + EXISTING_MAPPING=$(gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \ + --project="$PROJECT" --location=global \ + --workload-identity-pool="$POOL_ID" \ + --format='value(attributeMapping)') + if [[ "$(normalize_mapping "$EXISTING_MAPPING" ';')" != "$(normalize_mapping "$EXPECTED_MAPPING" ',')" ]]; then + die "provider '${PROVIDER_ID}' already exists with a different attribute mapping, so this script cannot vouch for which identities enter pool '${POOL_ID}'. + found : ${EXISTING_MAPPING:-(none)} + expected: ${EXPECTED_MAPPING} +A matching attribute condition is not sufficient on its own: the mapping +decides what value the condition is evaluated against, so a mismatched +mapping can satisfy the condition while admitting an identity this script +never authorised. +Delete the provider and re-run: +${DELETE_HINT}" + fi + echo "Reusing existing provider '${PROVIDER_ID}' (attribute condition and mapping match expected values)" fi # ── Grant service account impersonation to one principal ────────────────────── From 235eb7f3b83a6ae2bbd94cc505320075896dfeba Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 3 Aug 2026 13:02:15 +0200 Subject: [PATCH 2/2] fix(arm): compare GCP WIF reuse account/issuer for exact equality The provider-reuse check on setup-gcp-wif.sh ran a single describe with --format='value(aws.accountId,oidc.issuerUri)' and tested the tab-joined result with a suffix glob (OIDC) or prefix glob (AWS). Both are substring tests standing in for equality: an existing OIDC provider whose issuer merely ENDS with the expected one (e.g. an attacker-hosted https://evil.example.com/, an ordinary HTTPS URL GCP would fetch /.well-known/openid-configuration from) passed the check, as did an AWS account ID merely STARTING with the expected one. That made the attribute-condition and attribute-mapping checks below it vacuous, since they only bind the identity this check was supposed to have already pinned. Split into two separate describe calls, one per field, each compared with exact !=. Pre-existing in the reuse path added by #1651, not introduced by this PR. This is the fifth substring-standing-in-for- equality defect found in this codebase. --- arm/CUDly-CrossSubscription/setup-gcp-wif.sh | 34 ++++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/arm/CUDly-CrossSubscription/setup-gcp-wif.sh b/arm/CUDly-CrossSubscription/setup-gcp-wif.sh index 1a0657af4..c61ea49e5 100755 --- a/arm/CUDly-CrossSubscription/setup-gcp-wif.sh +++ b/arm/CUDly-CrossSubscription/setup-gcp-wif.sh @@ -260,18 +260,32 @@ else --workload-identity-pool="$POOL_ID" \ --format='value(attributeCondition)') # A provider of the other type would emit a credential config that does not - # match it and mint an attribute the grant below never names. - EXISTING_TYPE=$(gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \ - --project="$PROJECT" --location=global \ - --workload-identity-pool="$POOL_ID" \ - --format='value(aws.accountId,oidc.issuerUri)') - if [[ "$PROVIDER_TYPE" == "aws" && "$EXISTING_TYPE" != "${AWS_ACCOUNT_ID}"* ]]; then - die "provider '${PROVIDER_ID}' already exists but is not an AWS provider for account ${AWS_ACCOUNT_ID} (describe reports: ${EXISTING_TYPE}). Delete it and re-run: + # match it and mint an attribute the grant below never names. Compared as an + # exact match on the single relevant field, not a prefix/suffix test against + # the account-id/issuer-uri pair gcloud tab-joins under `value(a,b)`: a + # suffix test here would accept any issuer URI merely ENDING in the expected + # one (e.g. an attacker-hosted https://evil.example.com/, + # whose /.well-known/openid-configuration GCP would then fetch from the + # attacker, who can mint a token with any subject), and a prefix test would + # accept any account ID merely STARTING with the expected one. + if [[ "$PROVIDER_TYPE" == "aws" ]]; then + EXISTING_ACCOUNT_ID=$(gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \ + --project="$PROJECT" --location=global \ + --workload-identity-pool="$POOL_ID" \ + --format='value(aws.accountId)') + if [[ "$EXISTING_ACCOUNT_ID" != "$AWS_ACCOUNT_ID" ]]; then + die "provider '${PROVIDER_ID}' already exists but is not an AWS provider for account ${AWS_ACCOUNT_ID} (describe reports account: ${EXISTING_ACCOUNT_ID:-none}). Delete it and re-run: ${DELETE_HINT}" - fi - if [[ "$PROVIDER_TYPE" == "oidc" && "$EXISTING_TYPE" != *"${ISSUER_URI}" ]]; then - die "provider '${PROVIDER_ID}' already exists but is not an OIDC provider for issuer ${ISSUER_URI} (describe reports: ${EXISTING_TYPE}). Delete it and re-run: + fi + else + EXISTING_ISSUER=$(gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \ + --project="$PROJECT" --location=global \ + --workload-identity-pool="$POOL_ID" \ + --format='value(oidc.issuerUri)') + if [[ "$EXISTING_ISSUER" != "$ISSUER_URI" ]]; then + die "provider '${PROVIDER_ID}' already exists but is not an OIDC provider for issuer ${ISSUER_URI} (describe reports issuer: ${EXISTING_ISSUER:-none}). Delete it and re-run: ${DELETE_HINT}" + fi fi if [[ "$EXISTING_CONDITION" != "$EXPECTED_CONDITION" ]]; then die "provider '${PROVIDER_ID}' already exists with a different attribute condition, so this script cannot vouch for which identities enter pool '${POOL_ID}'.