Map
โ†‘Supply Chain Security

GitHub Actions Cache Poisoning

Wiki conceptsecuritygithub-actionsci-cd โ†ณ show in map Markdown
title
GitHub Actions Cache Poisoning
type
concept
summary
Attacker-controlled job writes cache entries that production workflows later restore
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

  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