Cache Poisoning Through pull_request_target: The TanStack Incident
Table of Contents
TL;DR
On May 11, 2026, a GitHub user zblgg opened PR #7378 against TanStack Router from a fork. The PR triggered a pull_request_target workflow that checked out the attacker’s code and ran it in the base repository’s context. Each push to the PR branch wrote a poisoned pnpm store to the shared GitHub Actions cache. When TanStack’s release pipeline later restored that cache, the malicious payload executed. The attacker then force-pushed the branch to match main HEAD (leaving 0 changes visible) and deleted it.
Impact:
- Attacker’s code executed inside the release pipeline with
contents: writeandid-token: writepermissions - The attacker exfiltrated a GitHub OAuth token (
gho_...) and stored it in a social engineering commit on their fork - The attacker used the stolen token to publish malicious TanStack packages to npm
Indicators of Compromise (IoC):
- GitHub user:
zblgg - Fork repository:
zblgg/configuration(fork ofTanStack/router, created 2026-05-10) - PR: TanStack/router#7378, branch
fix/history-package - Compromised Actions run: 25613093674/job/75429692202
- Token drop commit:
8542572e1a36onzblgg/configuration:testingbranch - Stolen token (double base64 encoded):
gho_HN198kK6NPPdvF7zexdJunq
The Vulnerability: pull_request_target + actions/cache
TanStack Router’s bundle-size.yml workflow used pull_request_target as its trigger. The workflow comment acknowledged the trust boundary concern:
# .github/workflows/bundle-size.yml (pre-incident)on: # We use `pull_request_target` to split trust boundaries across jobs: # - `benchmark-pr` checks out PR merge code and runs it as untrusted # with read-only permissions. # - `comment-pr` runs trusted base-repo code with limited write access # to upsert the PR comment. pull_request_target: paths: - 'packages/**' - 'benchmarks/**'The benchmark-pr job checked out PR code and ran it:
# benchmark-pr jobsteps: with: ref: refs/pull/${{ github.event.pull_request.number }}/merge fetch-depth: 0 persist-credentials: false - name: Setup Tools uses: TanStack/config/.github/setup@main - name: Measure Bundle Size run: pnpm nx run @benchmarks/bundle-size:buildThe two jobs split trust boundaries: benchmark-pr ran untrusted code with read-only permissions, comment-pr ran trusted base-repo code with write access. The flaw: TanStack/config/.github/setup@main included actions/cache for the pnpm store.
# TanStack/config/.github/setup/action.yml (pre-incident)- name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store-Because pull_request_target workflows execute in the base repository’s context (not the fork’s), actions/cache writes to the base repository’s cache scope. The job’s permissions block controlled API access but had no effect on cache writes. GitHub Actions cache operates at the repository level, outside the workflow permissions model, so a “read-only” job still writes to the shared cache through actions/cache’s post-run step.
The release.yml workflow, triggered by pushes to main, used the same shared setup action with the same cache key. Any poisoned entry written by the PR workflow would be restored verbatim into the release pipeline.
Attack Timeline
The attacker was methodical. Fork events from the GitHub API show the full sequence:
| Time (UTC, May 2026) | Event |
|---|---|
| May 10, 17:16 | Fork zblgg/configuration created from TanStack/router |
| May 11, 00:26 | Branch fix/history-package created |
| May 11, 00:28-00:59 | Three pushes (SHAs 6a0061a4, ec04e3d3, b1c061af), each triggering bundle-size.yml via pull_request_target |
| May 11, 01:05 | Branch deleted (first attempt abandoned or completed) |
| May 11, 10:46 | Branch re-created (second attempt) |
| May 11, 10:49 | PR #7378 opened as “WIP: simplify history build” (draft) |
| May 11, 10:49-11:11 | Four more pushes (SHAs 07b05e73, 97849378, dbdecb48, 65bf499d), each re-poisoning the cache |
| May 11, 11:31 | Final force-push to b1c061af (HEAD of main), PR closed, branch deleted. PR now shows 0 commits, 0 changes |
| May 11, 19:15 | Queued Release workflow job starts, restores poisoned cache |
| May 11, 19:20 | Poisoned cache payload executes, exfiltrates GitHub OAuth token |
| May 11, 19:50 | Attacker creates testing branch with token drop commit containing stolen OAuth token |
The attacker made at least 8 pushes across two attempts over 12 hours. Each push triggered the pull_request_target workflow, giving the attacker multiple iterations to refine the cache payload.
The Token Drop
After the release job ran, the attacker created a testing branch on their fork with an empty commit (0 files changed). The commit message:
IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner:WjJodlgwaE9NVGs0YTBzMlRsQlFaSFpHTjNwbGVHUktkVzV4Double base64 decoding reveals a GitHub OAuth token:
$ echo "WjJodlgwaE9NVGs0YTBzMlRsQlFaSFpHTjNwbGVHUktkVzV4" | base64 -d | base64 -dgho_HN198kK6NPPdvF7zexdJunqThe gho_ prefix identifies this as a GitHub OAuth access token, exfiltrated from the release pipeline’s runtime environment. The commit author is listed as “claude”, which could be misdirection or an indication of AI-assisted attack tooling.
Remediation Steps
The TanStack team responded within hours:
- May 11, 22:02 UTC: Tanner Linsley changed
bundle-size.ymlfrompull_request_targettopull_request(commit 5d92d5ae, commit 3ee179f0) - May 11, 22:57 UTC: Cache key busted by appending
-v2suffix inTanStack/config(PR #381) - May 12, 08:12 UTC: Caching entirely disabled in the shared setup action (PR #382)
The three-step escalation (fix trigger, bust cache, disable cache) shows appropriate caution. The team treated each fix as potentially incomplete and layered defenses until the cache vector was gone.
Why Trust Boundary Splitting Wasn’t Enough
The TanStack team did try to separate trusted and untrusted execution. The bundle-size.yml workflow split into two jobs: benchmark-pr ran attacker code with read-only permissions, comment-pr ran base code with write permissions and passed data only through job outputs (base64-encoded JSON).
The split prevents the attacker from writing to the repo or posting comments through the untrusted job. But actions/cache is an implicit side channel that operates outside the workflow permissions model. The cache store is shared across all workflows in the repository, and any job using actions/cache writes to it during the post-run phase, regardless of the job’s declared permissions block.
The fix required moving from pull_request_target to pull_request. Under pull_request, workflows from forks run in the fork’s context with restricted cache scope (fork PRs cannot write to the base repo’s cache). The tradeoff: pull_request workflows from forks cannot access secrets, so the comment-pr job needed restructuring to post PR comments without GITHUB_TOKEN from secrets.
Detection Guidance
Organizations running pull_request_target workflows should audit for this pattern:
- Does any
pull_request_targetworkflow check out PR code (github.event.pull_request.head.shaorrefs/pull/*/merge)? - Does the checked-out code path include
actions/cache,actions/setup-nodewith caching, or any other cache-writing action? - Do other workflows (release, deploy) share the same cache keys?
If all three are true, the cache is a cross-workflow persistence channel from untrusted PR code to trusted release pipelines.
For TanStack specifically: verify that any cached artifacts (pnpm store, Nx cache, build outputs) restored after May 11 were produced by trusted workflow runs. The cache key bust to -v2 should have invalidated all pre-incident entries.
References
- supply-chain-security
- github-actions
- ci-cd-security
- cache-poisoning
- tanstack
- incident-response
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Mass Supply Chain Attack Hits TanStack, Mistral AI npm and PyPI Packages
Over 400 compromised npm package versions and at least 2 PyPI packages published in a coordinated supply chain attack targeting TanStack, Mistral AI, UiPath, OpenSearch, guardrails-ai, and dozens of...

noon-contracts npm Package: DeFi Supply Chain RAT
noon-contracts poses as a Noon Protocol SDK on npm. On install it exfiltrates SSH keys, crypto wallet private keys, AWS credentials (including live STS/S3/SecretsManager calls), Kubernetes secrets,...

Endpoint Protection for Developer Machines
PMG blocks malicious package installs before post-install scripts run. Sync with SafeDep Cloud for fleet-wide visibility across your team's endpoints and CI runners.

martinez-polygon-clipping-tony: Trojanized npm Fork Drops Telegram RAT
martinez-polygon-clipping-tony is a trojanized fork of the legitimate martinez-polygon-clipping npm package. The postinstall hook downloads a PyInstaller-packed Telegram bot from 172.86.73.132 that...

Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.
