When cache keys contain fork-controllable references like github.head_ref or github.event.pull_request.head.ref, an attacker can craft a branch name that collides with a legitimate cache key. This lets them inject malicious content into the cache that will be restored by subsequent runs on trusted branches.
- uses: actions/cache@v4
with:
key: build-${{ github.head_ref }}-${{ hashFiles('**/package-lock.json') }}
path: node_modules
An attacker creates a fork branch named main (matching the base branch key), poisons the cache with modified node_modules, and subsequent runs on the real main branch restore the tainted cache.
Use hashFiles() for cache keys instead of branch refs:
# Before (fork-controllable key)
- uses: actions/cache@v4
with:
key: build-${{ github.head_ref }}-${{ hashFiles('**/package-lock.json') }}
path: node_modules
# After (content-addressed key)
- uses: actions/cache@v4
with:
key: build-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
path: node_modules
For workflows that run on PR triggers, use github.ref with a fork-isolated prefix:
key: pr-${{ github.event.number }}-${{ hashFiles('**/package-lock.json') }}
Cache poisoning is a persistent attack -- the tainted cache outlives the malicious PR and affects all subsequent builds until the cache key changes.