← Back to Research

The GitHub Actions Bug That Turns a Forked PR Into Write Access On Your Main Branch (CVE-2026-29901)

CVE-2026-29901 — CVSS 8.7. Affects actions/checkout v3.0.0 through v4.2.1 when the default persist-credentials: true runs under a pull_request_target trigger. A forked pull request can exfiltrate GITHUB_TOKEN and push directly to main.

Three mid-size open-source projects woke up in March 2026 to unexpected commits on main. All three had the same workflow pattern: pull_request_target firing actions/checkout with default settings. A forked pull request exfiltrated the GITHUB_TOKEN and pushed directly. CVE-2026-29901.

What the bug is

Affected: actions/checkout v3.0.0 through v4.2.1, when persist-credentials: true (the default) combines with the pull_request_target trigger. CVSS 8.7. The root cause isn't a code bug in checkout — it's a default-configuration footgun that every CI tutorial on the internet steps on.

After running, checkout leaves the ephemeral GITHUB_TOKEN inside .git/config so subsequent steps can push tags, open release pull requests, or publish artifacts. That's a perfectly reasonable behavior on a trusted branch. It is not a reasonable behavior when the working tree contains attacker-authored code from a forked pull request.

Why this combination is so dangerous

The pull_request_target event was introduced so workflows could run against external contributions with access to the parent repository's secrets — enabling things like coverage badges and automated labelling. The catch: the token scope is write, because the workflow runs in the parent-repository context, not the fork context. Any code that executes during that workflow runs with full write privileges on the upstream repository.

A vulnerable workflow typically looks like this:

on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4.2.1
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # fork's HEAD
      - run: npm install && npm test                     # runs fork's code

The ref: line fetches the attacker's commit. The next run: step executes it. The token is still in .git/config.

The attack chain in 4 steps

  1. Attacker forks the target repository and opens a pull request containing a malicious package.json postinstall script, a malicious test file, or any other executable hook the CI workflow invokes.
  2. The upstream workflow fires under pull_request_target. The checkout step writes GITHUB_TOKEN into .git/config in the runner workspace.
  3. The attacker's code executes during npm install or npm test. It reads .git/config, extracts the token, and base64-encodes it into a request to an attacker-controlled host — or, more cheaply, runs git push origin HEAD:main right there inside the runner.
  4. Upstream main now contains attacker commits. Release pipelines trigger. Downstream dependents pull poisoned artifacts. The only log trace is a "bot" push with the commit author the attacker chose.

The fix

Upgrade to actions/checkout@v4.2.2 or later. 4.2.2 changed the default to persist-credentials: false whenever ref resolves to a non-default branch under pull_request_target. Pin to a full commit SHA, not a tag — tags are mutable.

Belt and suspenders: set persist-credentials: false explicitly and drop pull_request_target entirely unless you have a concrete reason for it. A before-and-after diff:

# Before (vulnerable)
on:
  pull_request_target:
    types: [opened, synchronize]
jobs:
  test:
    steps:
      - uses: actions/checkout@v4.2.1
        with:
          ref: ${{ github.event.pull_request.head.sha }}

# After (safe)
on:
  pull_request:        # forks run in isolated context, no secrets
    types: [opened, synchronize]
jobs:
  test:
    steps:
      - uses: actions/checkout@v4.2.2
        with:
          persist-credentials: false

If you genuinely need pull_request_target (for example, to post coverage comments), split it into two workflows: one that runs untrusted code with no secrets, one that reads the artifact of the first and runs with secrets. Never combine them.

How Celvex Sentry catches this

Supply Chain Integrity scans every public repository in scope for vulnerable actions/checkout pins plus the pull_request_target trigger combination. When both match, the scanner opens an auto-PR containing the exact fix diff shown above — no ticket, no ceremony, a patch ready to merge. Offensive Simulation then validates the fix landed by attempting the token-extraction chain against a staging clone.

Pen-testers hand you a PDF once a year; Celvex Sentry runs every attack they would, every week, and proves the ones that still work — with a fix attached.

Sources

Get your exposure check — full report in 4-24 hours

Full report in 4-24 hours. Real assessment on production-grade infrastructure. Paying customers get priority capacity.

Queue My Assessment