| name | workflow-run-deploy-gate-fork-pr-ref-name-escalation |
| description | Security trap when authoring/reviewing a GitHub Actions deploy (or any privileged job)
triggered by `on: workflow_run` and gated on the triggering run's BRANCH NAME. Use when:
(1) a `workflow_run` job gates on `github.event.workflow_run.head_branch == 'main'`
(or conclusion == 'success') to decide a privileged deploy / publish / OIDC-cloud action,
(2) you are wiring CI-gated auto-deploy-on-merge ("deploy after the CI workflow passes on main"),
(3) reviewing a workflow that mints cloud creds (WIF/OIDC, `id-token: write`) off a `workflow_run`
event. The trap: `head_branch` is the UNQUALIFIED ref name, and a FORK's default branch is also
named `main`. A fork-PR's CI run (event `pull_request`) can complete `success` with
`head_branch=='main'`; the upstream `workflow_run` deploy then runs in the PRIVILEGED upstream
context and ships the fork's `head_sha` to prod. The "fork CI can't read secrets/OIDC" intuition
is a trap — that's the CI job; the deploy is a SEPARATE privileged job. Fix: also gate on the
triggering run's ORIGIN — `workflow_run.event == 'push'` AND
`workflow_run.head_repository.full_name == github.repository`. Unreachable on a private repo today,
but one-line fix + future-proofs a visibility flip.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-06-09T00:00:00.000Z" |
| disable-model-invocation | true |
workflow_run deploy gate: don't trust the ref name (fork-PR escalation)
Problem
You want auto-deploy after CI passes on main without a plain push trigger (e.g. so a red-CI
merge can't ship, while the required-CI merge gate is deferred). The idiomatic shape is:
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
deploy:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' # ⚠️ ref NAME only
permissions: { contents: read, id-token: write }
This is exploitable. workflow_run.head_branch is the unqualified ref name, and a fork's
default branch is also main. A workflow_run-triggered job runs in the privileged upstream
context (upstream secrets / OIDC), regardless of where the triggering run came from — that is
the inherent workflow_run hazard.
Attack path (public repo): fork → open PR from attacker-fork:main → upstream:main. The
fork's CI (on: pull_request) runs in the fork's unprivileged context, completes success, with
head_branch == 'main'. The upstream deploy workflow_run job then fires privileged, checks out the
attacker's head_sha, and builds+deploys attacker code to prod via the WIF/deploy SA.
The intuition that bites you: "fork CI can't access OIDC/secrets, so this is safe." True of the
CI job. The deploy is a separate workflow_run job that runs privileged no matter the
trigger's origin. Conclusion + branch-name are necessary but not sufficient.
Context / Trigger conditions
- A workflow with
on: workflow_run whose job if: gates a privileged action (deploy, publish,
release, gcloud/aws/az with WIF/OIDC, npm publish, image push) on
workflow_run.conclusion/head_branch only.
- The triggering CI workflow also runs on
pull_request (so fork PRs produce CI runs).
- Reviewing/authoring "deploy after CI succeeds on main" / "CI-gated auto-deploy-on-merge".
Solution
Gate on the triggering run's origin, not just its name/conclusion. Add BOTH:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' && # (A)
github.event.workflow_run.head_repository.full_name == github.repository) # (B)
- (A)
event == 'push' — a fork-PR CI run is event == 'pull_request'; only a write-access
push to main (a merge or a direct push) is event == 'push'. This alone closes the fork path.
- (B)
head_repository.full_name == github.repository — belt-and-suspenders: the CI run was on
our repo, not a fork. (For a push event this is already true; keep it for defence-in-depth.)
Neither guard blocks a legitimate deploy: the only legit path is a push-to-main CI run on your own
repo. Deploy the exact tested commit (workflow_run.head_sha), not the branch tip.
Note on scope: the workflow_run workflow file itself is always taken from the default
branch (tamper-safe — a feature branch can't change the gate logic). But its trigger is not, so
you must gate the trigger's origin in the if:.
Verification
- A fork PR (or any
pull_request CI run) must NOT produce a deploy: the workflow_run run shows
the gate job skipped (the if: is false). A non-success CI must also skip.
- A real merge to main → CI
success (event push) → the workflow_run run is created, not
skipped → gate + deploy jobs run. (A run that is "pending"/"in_progress" rather than immediately
"skipped" means the if: passed.)
- Confirm origin filtering in the run's event payload, or by attempting a same-name branch on a fork
in a test repo. Don't conflate a
workflow_dispatch or cancelled-CI run succeeding/ skipping with
proof the gated push path works — see deploy-gate-success-report-doesnt-prove-the-gated-path.
Example
Real incident (2026-06-09): a review panel flagged a CI-gated auto-deploy whose gate was
conclusion=='success' && head_branch=='main'. Repo was private (forks can't run CI without write
access → unreachable today), so one reviewer rated it P3; the security reviewer rated it a P1
ship-blocker on don't-trust-ref-name grounds. Fixed with guards (A)+(B) before merge — one-line if:
diff, future-proofs a private→public flip.
Notes
- Also applies to any
workflow_run-triggered privileged job, not just deploys (publish,
release, signing, infra apply).
- Distinct from
pull_request_target foot-guns (untrusted-code-with-secrets) — same threat class
(privileged context + attacker-influenced ref), different trigger.
- See also:
label-gated-deploy-pull-request-closed-needs-branches-filter (the sibling trigger-trap
on pull_request: closed — a label-gated deploy needs a branches:[main] filter);
deploy-gate-success-report-doesnt-prove-the-gated-path (verify the gated path actually ran,
not a happy-path/dispatch success); gha-auto-deploy-never-ran-skipped-mask (a gated workflow
showing "skipped" can mask a deploy that never successfully ran).
References
- GitHub Actions —
workflow_run event runs from the default branch with read/write GITHUB_TOKEN
by default; the triggering run's head_branch/head_repository/event are in
github.event.workflow_run.*. (GitHub Actions docs: "Events that trigger workflows → workflow_run";
"Security hardening for GitHub Actions".)