fix(ci): close shell injection into the npm publish job

A refname is attacker-controlled and git permits backtick, $, (, ; and |
in it. Three sites substituted it into a run: block, and NODE_AUTH_TOKEN
sat at job level, so a pushed tag ran arbitrary commands with the publish
credential in reach.

The tag now goes through env:, is validated against an anchored version
pattern before anything consumes it, and reaches the other jobs as a job
output. The token is scoped to the publish step. release-npm declares
contents: read instead of inheriting the repo default.
This commit is contained in:
pj committed 2026-08-16 01:00:40 +05:30
1 parent 5b6816956f
commit 7f9c5df7db
1 file changed
+49 -12
+49 -12
View File
@@ -11,26 +11,55 @@ on:
required: true required: true
type: string type: string
permissions:
contents: read
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
resolve-tag:
name: Resolve and validate the tag
runs-on: ubuntu-latest
permissions: {}
outputs:
tag: ${{ steps.tag.outputs.tag }}
version: ${{ steps.tag.outputs.version }}
steps:
# A refname is attacker-controlled text and git permits backtick, `$`,
# `(`, `;`, `&` and `|` in it, so it goes through env: a `${{ }}` is
# substituted before bash ever sees the line. Every later job reads these
# outputs rather than the refname, and nothing reaches a shell before it
# has matched the pattern. The pattern is anchored and admits no newline,
# which is what stops the value below forging a second $GITHUB_OUTPUT key.
- name: Validate the tag
id: tag
run: |
pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$'
if [[ ! "$TAG" =~ $pattern ]]; then
echo "release: refusing to publish from '$TAG'" >&2
echo "release: a release tag is vMAJOR.MINOR.PATCH with an optional -prerelease, e.g. v0.1.0 or v0.0.1-rc1" >&2
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ inputs.tag || github.ref_name }}
release-npm: release-npm:
name: Publish @sanderling/spec to npm name: Publish @sanderling/spec to npm
needs: resolve-tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: permissions:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} contents: read
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ needs.resolve-tag.outputs.tag }}
# `npm ci` below runs dependency lifecycle scripts, and no step in
- name: Resolve version # this job needs the git credential afterwards.
id: ver persist-credentials: false
run: |
raw="${{ inputs.tag || github.ref_name }}"
echo "version=${raw#v}" >> "$GITHUB_OUTPUT"
- name: Set up Node 22 - name: Set up Node 22
uses: actions/setup-node@v7 uses: actions/setup-node@v7
@@ -46,28 +75,36 @@ jobs:
- name: Stamp version - name: Stamp version
working-directory: pkg/spec working-directory: pkg/spec
run: npm version ${{ steps.ver.outputs.version }} --no-git-tag-version --allow-same-version run: npm version "$VERSION" --no-git-tag-version --allow-same-version
env:
VERSION: ${{ needs.resolve-tag.outputs.version }}
- name: Publish - name: Publish
working-directory: pkg/spec working-directory: pkg/spec
# npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @sanderling/spec # npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @sanderling/spec
# keeps resolving the latest stable. # keeps resolving the latest stable.
run: | run: |
if [[ "${{ steps.ver.outputs.version }}" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
npm publish --access public --tag next npm publish --access public --tag next
else else
npm publish --access public npm publish --access public
fi fi
# The publish credential is scoped to the one step that publishes rather
# than to the job, so no other step runs with it in reach.
env:
VERSION: ${{ needs.resolve-tag.outputs.version }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release-cli: release-cli:
name: Publish sanderling CLI to GitHub Releases name: Publish sanderling CLI to GitHub Releases
needs: resolve-tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ needs.resolve-tag.outputs.tag }}
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go