← Actions Cache Poisoning Check

What is GitHub Actions cache poisoning?

GitHub Actions caches are scoped to the whole repository, not to a branch, a workflow, or a trust level. pull_request, the ordinary PR trigger, runs with a read-only token and no secrets — a cache it writes is not especially dangerous, because nothing valuable trusts it either. pull_request_target and workflow_run exist for jobs that need more: commenting on a PR, or building it with real credentials. They run in the context of the base repository, with the base repository's secrets — but the event that triggers them is still raised by whoever opened the pull request, who might be a stranger.

If a job on one of those two triggers writes to the cache, the stranger controls what gets written:

on: pull_request_target
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: ~/.pnpm-store
          key: pnpm-${{ github.event.pull_request.head.sha }}
      - run: pnpm install

actions/cache restores on a hit and saves on a miss, so this step writes a cache entry the fork PR fully controls the contents of. If a separate, privileged workflow — a release build, a deploy step, anything triggered by a push to a protected branch — later restores from the same key namespace, it can pick up the fork's poisoned entry and run it with real secrets.

A real incident

In May 2026, a pull request opened against TanStack Router's pull_request_target workflow wrote a poisoned pnpm store to the shared Actions cache on every push. The project's own release pipeline later restored that cache and executed the payload. Independent security researchers (AdnaneKhan's ActionsCacheBlasting proof-of-concept, Hive Security's cache-poisoning writeup) had already documented the underlying mechanism: caches are not segregated by workflow, event type, or trust level, and a restore-keys fallback can adopt an entry that merely shares a key prefix with the one you actually asked for — described as "all keys are restore keys" in practice.

The fix

Check your own workflows

Paste your workflow YAML into Actions Cache Poisoning Check to find every fork-writable cache step and unscoped restore-keys fallback across every workflow file at once, or run the CLI in your own repository:

python3 check.py --fail-on-risk

That exits with a nonzero status if any workflow has either pattern — wire it into your own CI to block a newly-added one before it merges.