Skip to content

Harden release workflows against tag-derived script injection - #2119

Merged
erubboli merged 1 commit into
masterfrom
fix/release-workflow-version-injection
Sep 19, 2026
Merged

erubboli merged 1 commit into
masterfrom
fix/release-workflow-version-injection

Conversation

@nullPointerEnjoyer

Copy link
Copy Markdown
Contributor

Summary

Closes a script-injection gap in the release pipeline: a refname-legal hostile tag (e.g. 1.2.3$(id>>proof.txt)) matches the '**[0-9]+.[0-9]+.[0-9]+*' push-tags filter and, before this change, reached shell execution in three places:

  • release_macos.ymlSign and Notarize GUI spliced ${{ steps.get_version.outputs.VERSION }} directly into the run: block. GitHub's runner performs the expression substitution textually before bash parses the script, so $(...) from the tag name executed during word expansion — before sign_and_notarize.sh even ran — in a step whose environment holds MACOS_CERTIFICATE_BASE64, MACOS_CERTIFICATE_PASSWORD, APPLE_ID and APPLE_ID_PASSWORD. This job runs in parallel with the Linux/Windows jobs (no needs gate), so it fires even though those jobs reject non-semver tags.
  • release_docker.yml — unvalidated version interpolated into the build/push step whose argument flows into docker build/push command strings executed with shell=True in build-tools/docker/build.py.
  • release.yml (create-release) — version (and artifact hashes) interpolated into the release-body run: block.

The payload rides the tag name, not commit content, so commit review does not see it; it can point at any existing reviewed commit.

The Linux and Windows workflows already enforce the intended invariant — release_linux.yml:63 / release_windows.yml:51 validate the version against ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$, and release_windows.yml passes it via environment variables with the comment "so that a crafted tag/ref/input cannot inject shell into the steps"; packaging/common/lib.sh validate_version guards the same input for packages. This PR applies the same two controls to the missed paths:

  1. Validate up front — every consumer of the tag-derived version now rejects non-semver tags with a clear tag a semver release error before anything privileged runs (docker accepts the optional leading v it forwards to build.py, which strips it).
  2. Env-var indirection — the version, the Docker Hub username and the artifact hashes are passed through env: and used as quoted shell variables ("$VERSION"), never spliced via ${{ }} into run: scripts, so a crafted tag/ref/input is inert data.

GitHub's own guidance recommends exactly this pattern ("use an intermediate environment variable" for mitigating script injection attacks).

Verification

  • YAML parses; bash -n clean on all 9 run: blocks across the three files.
  • Extracted the patched get_version/version steps and executed them with GITHUB_REF set to 7 tag cases × 3 workflows: 1.2.3$(id>>proof.txt), 1.2.3$(curl${IFS}…|sh), 1.2.3\id`, 1.2.3;id→ all **rejected** with exit 1;v0.4.1, 0.4.1, v0.4.1-rc.1` → all accepted.
  • Interpolation audit: the only ${{ }} expression remaining inside any run: block is ${{ matrix.arch }} (workflow-defined strategy value).
  • Before/after mechanics check locally: with the old pattern, $(id>>proof.txt) executed during expansion (file created before the signing script ran); with the new pattern the hostile string reaches the script as inert argument data and nothing executes.

Notes for reviewers

  • Behavior change on hostile/non-semver tags only: the affected jobs now fail fast with derived version '…' is not X.Y.Z[-suffix]; tag a semver release instead of proceeding.
  • Remaining defense-in-depth (hosted settings, not code): consider a tag-protection ruleset restricting who can create tags matching the release pattern, and SHA-pinning the third-party actions on the release path.
  • Artifact names (Mintlayer_Node_macos_${{ … }}_${{ matrix.arch }} in with: blocks) are action inputs rather than shell, and now carry a validated version, so they were left unchanged.

The tag-derived version was interpolated via ${{ }} expressions directly
into run blocks in release_macos.yml, release_docker.yml and the
create-release job of release.yml. A refname-legal hostile tag such as
1.2.3$(cmd) matches the '**[0-9]+.[0-9]+.[0-9]+*' push-tags filter and
reaches these steps unvalidated, so bash executes the command
substitution during expansion - before sign_and_notarize.sh runs and
with the Apple signing credentials in the step environment. The Linux
and Windows workflows already validate the version and pass it through
environment variables for exactly this reason; the macOS job (which runs
in parallel and has no needs gate), the docker job and the release body
step were missed.

Apply the same two controls everywhere:

- Validate the extracted version against the shared X.Y.Z[-suffix]
  grammar up front and fail the job with a clear message on non-semver
  tags (docker also accepts the leading 'v' it passes to build.py).
- Pass the version (and the Docker Hub username / hashes) through
  environment variables and quote them, instead of splicing ${{ }}
  expressions into run scripts, so a crafted tag/ref/input is treated as
  inert data and cannot inject shell into the steps.
@github-actions

Copy link
Copy Markdown

OpenCodeReview: Review complete: 0 finding(s) across 3 selected item(s).

@erubboli
erubboli merged commit 6b20c77 into master Sep 19, 2026
21 checks passed
@erubboli
erubboli deleted the fix/release-workflow-version-injection branch September 19, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants