Skip to content

Commit 8ac7358

Browse files
iunanuaclaude
andauthored
fix(ci): harden release-proposal-dispatch against untrusted main_start_ref (#2045)
# What does this PR do? Harden `release-proposal-dispatch` against untrusted `main_start_ref` Block three privilege-escalation paths for an authorised release operator who selects an attacker-controlled `main_start_ref`: - Reject `refs/pull/*` and require the resolved commit be reachable from `origin/main` or the matching `origin/hotfix/<crate>/N.x.x` branch. - Reject `pre-release-hook` / `pre-release-replacements` anywhere in the checked-out `Cargo.toml` / `release.toml`, since cargo-release would execute them with the job's OIDC mint capability. - Move `inputs.main_start_ref` from inline template expansion into an `env:` mapping on the ephemeral-branch step to close a shell-injection sink. Also extract the hotfix branch regex into `HOTFIX_REF_PATTERN` at workflow level so all three call sites stay in sync. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent af9b62b commit 8ac7358

1 file changed

Lines changed: 53 additions & 4 deletions

File tree

.github/workflows/release-proposal-dispatch.yml

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ env:
5555
MAIN_BRANCH: main
5656
RELEASE_BRANCH_PREFIX: ${{ inputs.bypass_standard_checks && 'release-testing' || 'release' }}
5757
PROPOSAL_BRANCH_PREFIX: ${{ inputs.bypass_standard_checks && 'release-proposal-testing' || 'release-proposal' }}
58+
HOTFIX_REF_PATTERN: '^hotfix/[^/]+/[0-9]+\.x\.x$'
5859

5960
jobs:
6061
check-proposal-ongoing:
@@ -247,10 +248,36 @@ jobs:
247248
echo "Try a full SHA, a branch/tag name on origin, or refs/heads/... / refs/tags/..." >&2
248249
exit 1
249250
fi
251+
252+
# Reject pull-request refs outright: they can be pushed by anyone with a fork.
253+
case "$REF" in
254+
refs/pull/*|pull/*)
255+
echo "Error: refs/pull/* refs are not allowed as main_start_ref." >&2
256+
exit 1
257+
;;
258+
esac
259+
260+
# Verify the resolved commit is reachable from a trusted ref before checking it out.
261+
# Trusted: origin/${{ env.MAIN_BRANCH }}, or the matching origin/hotfix/<crate>/N.x.x branch.
262+
TRUSTED=false
263+
if git merge-base --is-ancestor "$COMMIT" "origin/${{ env.MAIN_BRANCH }}" 2>/dev/null; then
264+
TRUSTED=true
265+
elif [[ "$REF" =~ $HOTFIX_REF_PATTERN ]] \
266+
&& git rev-parse -q --verify "origin/${REF}^{commit}" >/dev/null 2>&1 \
267+
&& git merge-base --is-ancestor "$COMMIT" "origin/${REF}" 2>/dev/null; then
268+
TRUSTED=true
269+
fi
270+
271+
if [ "$TRUSTED" != "true" ]; then
272+
echo "Error: resolved commit ${COMMIT} is not reachable from origin/${{ env.MAIN_BRANCH }} or a recognised origin/hotfix/<crate>/N.x.x branch." >&2
273+
echo "main_start_ref must point at a commit on a trusted branch." >&2
274+
exit 1
275+
fi
276+
250277
# Hotfix releases use the hotfix branch itself as the ephemeral branch.
251278
# If a hotfix branch name was provided and exists on origin, check it out as a branch
252279
# (not a detached commit) so later steps can use it as a PR base.
253-
if [[ "$REF" =~ ^hotfix/[^/]+/[0-9]+\.x\.x$ ]] && git rev-parse -q --verify "origin/$REF^{commit}" >/dev/null 2>&1; then
280+
if [[ "$REF" =~ $HOTFIX_REF_PATTERN ]] && git rev-parse -q --verify "origin/$REF^{commit}" >/dev/null 2>&1; then
254281
git checkout -B "$REF" "origin/$REF"
255282
echo "Release cut from hotfix branch '$REF' -> $(git rev-parse --short HEAD) ($(git log -1 --oneline))"
256283
else
@@ -262,15 +289,37 @@ jobs:
262289
git reset --hard "origin/${{ env.MAIN_BRANCH }}"
263290
echo "Release cut from origin/${{ env.MAIN_BRANCH }} tip ($(git rev-parse --short HEAD))."
264291
fi
265-
292+
293+
- name: Reject untrusted cargo-release configuration
294+
run: |
295+
set -euo pipefail
296+
# cargo-release supports pre-release-hook (arbitrary command execution) and
297+
# pre-release-replacements (arbitrary file rewriting). Neither is used by this repo,
298+
# so reject any tree that mentions them. This stops a malicious main_start_ref from
299+
# executing code with the job's OIDC mint capability via the cargo-release step.
300+
#
301+
# The match is intentionally broad: it catches every TOML key form cargo-release
302+
# accepts — bare `pre-release-hook = ...`, dotted `package.metadata.release.pre-release-hook = ...`,
303+
# quoted `"pre-release-hook" = ...`, inline-table `{ pre-release-hook = ... }`, and
304+
# array-of-table `[[package.metadata.release.pre-release-replacements]]`. `^[^#]*`
305+
# excludes the substring when it only appears after a `#` comment marker.
306+
FORBIDDEN=$(grep -rEn '^[^#]*(pre-release-hook|pre-release-replacements)' \
307+
--include='Cargo.toml' --include='release.toml' . || true)
308+
if [ -n "$FORBIDDEN" ]; then
309+
echo "Error: forbidden cargo-release configuration detected in the checked-out tree:" >&2
310+
echo "$FORBIDDEN" >&2
311+
exit 1
312+
fi
313+
266314
- name: Create ephemeral release branch
267315
id: ephemeral-branch
316+
env:
317+
MAIN_START_REF: ${{ inputs.main_start_ref }}
268318
run: |
269319
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
270-
MAIN_START_REF="${{ inputs.main_start_ref }}"
271320
REF="$(echo "${MAIN_START_REF:-}" | tr -d '[:space:]')"
272321
273-
if [[ -n "$REF" && "$REF" =~ ^hotfix/[^/]+/[0-9]+\.x\.x$ ]]; then
322+
if [[ -n "$REF" && "$REF" =~ $HOTFIX_REF_PATTERN ]]; then
274323
# Hotfix: use the hotfix branch itself as the ephemeral branch (no new branch created).
275324
if ! git rev-parse -q --verify "origin/$REF^{commit}" >/dev/null 2>&1; then
276325
echo "Error: hotfix branch does not exist on origin: $REF" >&2

0 commit comments

Comments
 (0)