From d53ee9f9e432cd833b6422c93af6c19ce32ac497 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 7 Jun 2026 19:27:50 -0700 Subject: [PATCH] fix(ci): pr-title-sync covers fork PRs via hardened pull_request_target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under plain pull_request the GITHUB_TOKEN is read-only on fork PRs, so the title-sync backstop could never edit a fork/agent PR title. Switch to pull_request_target (write token in base context) and make it safe: - Check out the base repo only (no ref:) — execute trusted infra, never fork-head code. - All attacker-controlled PR fields (title, head repo, head sha) pass via env: and are referenced as shell-quoted "$VAR", never inlined into run:. - Read the PR-head VERSION as data (raw media type) from the head repo at the head sha; guard the assignment under set -e. - Same-repo read failure fails loudly; fork miss warns and skips (the backstop stays green without going silently optional). - Never echo the raw fork title (Actions parses ::workflow-command:: from stdout). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pr-title-sync.yml | 71 +++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-title-sync.yml b/.github/workflows/pr-title-sync.yml index 6f5b3d3e5..4f94d4db9 100644 --- a/.github/workflows/pr-title-sync.yml +++ b/.github/workflows/pr-title-sync.yml @@ -1,7 +1,25 @@ name: PR Title Sync +# WHY pull_request_target (not pull_request): the default GITHUB_TOKEN is +# READ-ONLY on fork PRs under `pull_request`, so the title-sync backstop could +# never `gh pr edit` a fork/agent PR. `pull_request_target` runs in the base-repo +# context with a write token, which fixes fork coverage. +# +# WHY this is SAFE (pull_request_target is the most dangerous trigger): +# - We check out the BASE repo (no `ref:`), so the only code we execute is +# trusted base-repo infra (bin/gstack-pr-title-rewrite.sh). We NEVER check +# out or run PR-head/fork code. +# - Every attacker-controlled PR field (title, head repo, head sha) arrives via +# `env:` and is referenced as a shell-quoted "$VAR". We NEVER inline a +# `${{ github.event.pull_request.* }}` expression inside the run: script +# (that would execute a crafted title as shell). +# - The PR-head VERSION is read as DATA via the API (raw media type), from the +# head repo at the head sha — never by checking out the head. +# test/pr-title-sync-workflow-safety.test.ts is the static tripwire for all of +# the above and fails CI if any of it regresses. + on: - pull_request: + pull_request_target: types: [opened, synchronize, edited] paths: - 'VERSION' @@ -19,25 +37,62 @@ jobs: pull-requests: write if: github.actor != 'github-actions[bot]' steps: - - name: Checkout PR head + # Base repo only — trusted infra (the rewrite helper). No PR-head checkout. + - name: Checkout base repo (trusted) uses: actions/checkout@v4 with: fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} - name: Rewrite PR title to match VERSION env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUM: ${{ github.event.pull_request.number }} + # Attacker-controlled on fork PRs — env-only, never inlined into run:. OLD_TITLE: ${{ github.event.pull_request.title }} + BASE_REPO: ${{ github.repository }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail chmod +x ./bin/gstack-pr-title-rewrite.sh - VERSION=$(cat VERSION | tr -d '[:space:]') - NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE") - if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then - echo "Title already correct; no change." + + if [ "$HEAD_REPO" = "$BASE_REPO" ]; then IS_FORK=0; else IS_FORK=1; fi + + # Read the PR-head VERSION as data (raw bytes), from the head repo at + # the head sha. Guard the assignment itself: under `set -e` a bare + # `VERSION=$(...)` would abort the step before any later [ -z ] check. + if ! VERSION=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$HEAD_REPO/contents/VERSION?ref=$HEAD_SHA" 2>/dev/null | tr -d '[:space:]'); then + VERSION="" + fi + + if [ -z "$VERSION" ]; then + # Same-repo read failure should never happen — fail loudly so we + # notice. A fork miss (public-contents quirk, private fork) is a + # convenience gap, not a gate — warn and skip so the check stays green. + if [ "$IS_FORK" = "0" ]; then + echo "::error::Could not read VERSION from same-repo PR head ($HEAD_SHA)." + exit 1 + fi + echo "::warning::Could not read VERSION from fork $HEAD_REPO ($HEAD_SHA); skipping title sync." + exit 0 + fi + + # The helper rejects a malformed VERSION (exit 2). Same policy: loud for + # same-repo, soft for forks. Never echo the raw (attacker-controlled) + # title — Actions still parses ::workflow-command:: from stdout. + if ! NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE"); then + if [ "$IS_FORK" = "0" ]; then + echo "::error::Could not compute title for VERSION '$VERSION' on PR #$PR_NUM." + exit 1 + fi + echo "::warning::Could not compute title for fork PR #$PR_NUM; skipping." + exit 0 + fi + + if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then + echo "PR #$PR_NUM title already correct; no change." exit 0 fi - echo "Rewriting: $OLD_TITLE -> $NEW_TITLE" gh pr edit "$PR_NUM" --title "$NEW_TITLE" + echo "PR #$PR_NUM title synced to VERSION."