deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

GitHub Actions pull_request_target pwn requests: how fork PRs reach your secrets

A dev.to explainer details how the pull_request_target trigger exposes secrets to fork pull requests, the Ultralytics attack path, and GitHub's 2025-2026 guardrails.

GitHub Actions pull_request_target pwn requests: how fork PRs reach your secrets

The pull_request_target trigger in GitHub Actions runs a workflow from a repository's default branch with full secrets and a read/write token, even when the pull request arrives from an untrusted fork. That combination is safe only as long as nothing from the fork ever executes, and a dev.to explainer walks through exactly how often that line gets crossed, what it cost the Ultralytics project, and the guardrails GitHub has shipped since.

What each trigger actually gets

The distinction, as the article lays it out, is where the workflow file comes from and what it is handed. A plain pull_request event runs the workflow found at the pull request's merge commit — content the fork controls — so GitHub gives it a read-only token and withholds secrets entirely. pull_request_target inverts this: the workflow file comes from the base repository's default branch, which the maintainer controls, so GitHub grants a read/write GITHUB_TOKEN plus repository and organization secrets, a fact the article traces to GitHub's own workflow syntax documentation. workflow_run sits in the same privileged row, running from the default branch with access to secrets and write tokens even when the workflow before it had none. Caching differs too: a fork-triggered pull_request keeps its cache writes inside the merge ref, while the privileged triggers see the default-branch cache scope.

How a pwn request happens

The classic mistake is four lines: check out the pull request's head SHA, then run a build or test step. The checkout alone executes nothing, but the next step does — make test runs the fork's Makefile, npm install runs lifecycle scripts from the fork's package.json, and linters and test runners load fork-controlled configuration and plugins. All of it happens inside a job holding the repository's credentials. GitHub's guidance, quoted in the piece, is that code checked out this way must be treated as data and never executed. The same exposure exists without actions/checkout: fetching the pull request ref manually, running gh pr checkout, or downloading and executing an artifact that a fork's pull_request run uploaded.

Ultralytics: injection without a checkout

The December 2024 compromise of the ultralytics Python package shows the script-injection half of the problem. According to William Woodruff's analysis, cited in the article, a formatting workflow on pull_request_target called a composite action that ran git pull with the branch name spliced directly into a shell command. Pull request #18020 used a branch name that was itself a shell command, with ${IFS} substituted for spaces, and it executed 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 would silently run an xmrig miner. Woodruff's evidence points to a poisoned cache being used to reach the release build, while PyPI's analysis found that 8.3.45 and 8.3.46 were uploaded directly using an unrevoked PyPI API token still available to the workflow. No head checkout was involved — fork-controlled data executed with trusted credentials all the same. The article also names the Nx compromise as another incident routed through this trigger.

What GitHub changed, and what each change misses

Four changes now stand between a pwn request and your secrets, according to the article. Since December 8, 2025, the workflow file and default checkout always come from the default branch, though nothing stops a default-branch workflow from fetching whatever it likes. actions/checkout v7.0.0 refuses to check out fork pull request code inside pull_request_target and workflow_run unless the step opts in with allow-unsafe-pr-checkout: true, but workflows pinned to v6 or older, raw git commands, downloaded artifacts and same-repository branches are unaffected. Since June 26, 2026, untrusted triggers get read-only access to the default-branch cache, unless a job declares a write-capable cache mode. Finally, a default Actions policy blocks pull_request_target in public repositories, enforced on December 2, 2026 after brownouts on November 9 and 16 — leaving private and internal repositories, GitHub Enterprise Server, and any repository that opts in unaffected.

The pattern that holds up

The durable fix, drawn from GitHub Security Lab guidance originally published in 2021 and updated in 2026, is to split the work across two workflows. One 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 only the permissions it genuinely needs, downloads the artifact and acts on it purely as data — posting a coverage summary or a pull request comment, for instance. The lab's rule is that artifacts produced from untrusted pull request data are themselves untrusted: a fork can edit the first workflow's file, so every field must be validated, pull request numbers must be checked as digits, and report contents must never be executed or sourced.

Why it matters

pull_request_target exists because maintainers legitimately need privileged steps on fork contributions — reaching private registries, uploading coverage, commenting on pull requests. The trigger is not going away, and the newest policy protects public repositories by default only. Understanding the split between untrusted execution and trusted follow-up work, and treating every fork-supplied value as hostile input — whether a Makefile, a branch name or an artifact field — is the difference between convenient CI and handing strangers your release credentials.

  • #github-actions
  • #security
  • #ci-cd
  • #supply-chain
  • #devops

Related posts