# GitHub Actions Cache Poisoning

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

1. **`actions/cache@v…`'s post-job save bypasses the workflow `permissions:` block.** The cache step authenticates with an internal token issued by the runner to the cache service. It is not the workflow's `GITHUB_TOKEN`. Setting `permissions: contents: read` does not stop cache writes.

2. **Cache scope is per-repository, shared across triggers.** A `pull_request_target` run executes in the base repo and so writes to the base repo's cache scope. A `pull_request` from a fork is restricted but still reads/writes within rules. A `push` to `main` looks 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 against `refs/pull/.../merge` and a push running against `refs/heads/main` will 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 like `actions/setup-node`) is a writable poison vector. Either remove the `pull_request_target` + fork-checkout, or remove the cache from that workflow.

**Tooling.**

- `zizmor` catches 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/>
