GitHub Actions Cache Poisoning
- title
- GitHub Actions Cache Poisoning
- type
- concept
- summary
- Attacker-controlled job writes cache entries that production workflows later restore
- parent
- supply-chain-security
- tags
- security, github-actions, ci-cd
- created
- 2026-05-12
- updated
- 2026-05-12
GitHub Actions has a per-repository build cache (actions/cache). Any workflow run in a repository can read and write entries in the same cache namespace, including runs triggered by fork PRs. If an attacker can run code inside any workflow on a repo, they can write entries that production workflows on main will later restore.
The vector was documented in detail by Adnan Khan in May 2024 ("The Monsters in Your Build Cache"). It is not a TanStack bug, not a TanStack-specific bug, and not a recently-fixed bug. It is a design property of how Actions caches scope and authenticate.
The two non-obvious mechanics
-
actions/cache@vโฆ's post-job save bypasses the workflowpermissions:block. The cache step authenticates with an internal token issued by the runner to the cache service. It is not the workflow'sGITHUB_TOKEN. Settingpermissions: contents: readdoes not stop cache writes. -
Cache scope is per-repository, shared across triggers. A
pull_request_targetrun executes in the base repo and so writes to the base repo's cache scope. Apull_requestfrom a fork is restricted but still reads/writes within rules. Apushtomainlooks up keys in the same namespace either of those PR runs may have populated.
The combination: get any kind of code execution in a workflow that runs actions/cache's post-step (a pwn-request-pattern is the easiest route), write a malicious entry under a key the production workflow will later compute, wait. When the next merge to main triggers the release workflow, actions/cache's restore step pulls your entry in. The malicious files are now sitting on the runner's disk.
What the attacker writes
The TanStack incident is the canonical worked example. The malicious vite_setup.mjs wrote into the pnpm-store directory and saved under the key Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}. That key is what the legitimate release.yml workflow would compute when it ran its own Setup Tools step on the next push to main. The poisoning was targeted at a specific future workflow's specific future cache key.
Once restored, the poisoned pnpm-store contained binaries the next build steps would invoke. Those binaries then did what code on the runner can always do โ but with the release workflow's id-token: write permission, leading to ci-runner-token-extraction.
Defenses
Best: don't share cache scopes across trust boundaries.
- The release workflow should pull dependencies fresh, or restore only from a cache that no PR-triggered workflow can write to. There is no native "trusted cache scope" in Actions; the workable patterns are (a) skip cache entirely on release, or (b) use a separate cache key prefix that PR workflows are structurally forbidden from matching.
Good: scope keys defensively.
- Add
${{ github.ref }}or a branch-name component to cache keys for release workflows so PR runs cannot predict / collide with them. A PR running againstrefs/pull/.../mergeand a push running againstrefs/heads/mainwill hash to different keys.
Practical: audit pull_request_target workflows for cache calls.
- Anything matching the pwn-request-pattern plus
actions/cache(directly or transitively, via composite actions likeactions/setup-node) is a writable poison vector. Either remove thepull_request_target+ fork-checkout, or remove the cache from that workflow.
Tooling.
zizmorcatches the pattern.- GitHub Actions cache API lets you purge a poisoned cache (the TanStack team did this for all
TanStack/*repos as the cleanup step), but this is reactive, not preventive.
Why "set permissions to read-only" doesn't help
The naive fix โ "set permissions: contents: read on the untrusted job and call it sandboxed" โ does not work because the cache write doesn't use those permissions. This is the single most common false sense of safety in real-world workflows.
See also
- pwn-request-pattern โ the most common way attackers get the code execution to poison
- ci-runner-token-extraction โ what poisoned binaries do once the cache is restored on a privileged runner
- supply-chain-security โ the broader concept
- tanstack-npm-supply-chain-postmortem โ the chain
- Adnan Khan's writeup: https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/