# Pwn Request Pattern

GitHub Actions has two PR triggers. `pull_request` runs against the fork's code with the fork's secret scope — empty by default — and requires first-time-contributor approval. `pull_request_target` runs against the base repo with the base repo's secret scope and *does not* require that approval, because it was designed for trusted operations (labeling, commenting, posting deploy previews) on untrusted PRs.

The "pwn request" pattern is: a `pull_request_target` workflow that, somewhere in its body, runs fork-controlled code. The classic shape is:

```yaml
on:
  pull_request_target:

jobs:
  build:
    steps:
      - uses: actions/checkout@v…
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      - run: <build / test / lint>
```

That `checkout` line — checking out the PR's *merge* ref (or the fork head) — pulls in the fork's code. The `run` step then executes it. Because the trigger is `pull_request_target`, the workflow has base-repo `GITHUB_TOKEN` permissions, base-repo secrets, and (often) `id-token: write`. A stranger has just gotten code execution in your repo's trust context.

GitHub Security Lab named this the "pwn request" pattern in 2021 and the name stuck.

## What makes it dangerous beyond the obvious

Two non-obvious facts make the blast radius larger than people expect:

- **The `permissions:` block does not control everything.** `actions/cache@v…`'s post-job save uses a runner-internal token, not the workflow `GITHUB_TOKEN`. Setting `permissions: contents: read` does not block cache writes. See [[github-actions-cache-poisoning]].
- **Mid-workflow trust splits leak.** If you set `permissions: contents: read` and tell yourself "the build is untrusted, the comment job is trusted," you have *split intent*, not *split execution*. The build still runs in your base repo's environment, can still read everything on disk the runner image carries, can still write to the cache.

## The two-workflow defense

The recommended fix is structural. Split into two workflows:

1. **A `pull_request_target` workflow** that does only what `pull_request_target` was designed for: label, comment, gate. It does not check out fork code. It does not run fork code. If it dispatches a build, it does so via `workflow_dispatch` after explicit human approval.

2. **A `pull_request`-triggered (or `workflow_dispatch`-triggered) workflow** that does run the build. It runs against the fork's code with fork-empty secrets, gated by first-time-contributor approval. Anything that needs real secrets to validate happens after a human says yes.

The TanStack incident was caused by trying to combine both shapes in one file with a same-file trust split. The split was correct in spirit and insufficient in mechanism. Once `actions/checkout` of the fork merge ref had run inside the `pull_request_target` context, the rest was inevitable.

## Detection

- `zizmor` lints GitHub Actions YAML and flags `pull_request_target` + `actions/checkout` of a fork ref as a "pwn request" hit.
- GitHub's own security-lab pwn-request reference: <https://securitylab.github.com/research/github-actions-preventing-pwn-requests/>
- Adnan Khan's writeup of the broader cache-poisoning variant: <https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/>

## Notable incidents

- **2026-05-11** — TanStack. `bundle-size.yml` ran `pull_request_target` and built fork code. Cache poisoned. See [[tanstack-npm-supply-chain-postmortem]] for the full chain.
- **2025-03** — tj-actions/changed-files. Slightly different vector (tag force-push on an unpinned action) but the same outcome: code execution in dependents' release workflows, OIDC token theft.

## See also

- [[supply-chain-security]]
- [[github-actions-cache-poisoning]] — the cache-write amplification that turns one pwn request into a long-lived persistence vector
- [[ci-runner-token-extraction]] — what attackers do *once* they have execution
- [[tanstack-npm-supply-chain-postmortem]] — the chain in action
