Map
โ†‘Supply Chain Security

Pwn Request Pattern

Wiki conceptsecuritygithub-actionsci-cd โ†ณ show in map Markdown
title
Pwn Request Pattern
type
concept
summary
GitHub's pull_request_target running fork-controlled code in the base repo's permission context
tags
security, github-actions, ci-cd
created
2026-05-12
updated
2026-05-12

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:

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

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