Cache Poisoning Through pull_request_target: The TanStack Incident

SafeDep Team
6 min read

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: write and id-token: write permissions
  • 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 of TanStack/router, created 2026-05-10)
  • PR: TanStack/router#7378, branch fix/history-package
  • Compromised Actions run: 25613093674/job/75429692202
  • Token drop commit: 8542572e1a36 on zblgg/configuration:testing branch
  • 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 job
steps:
- uses: actions/[email protected]
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:build

The 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
uses: actions/[email protected]
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:16Fork zblgg/configuration created from TanStack/router
May 11, 00:26Branch fix/history-package created
May 11, 00:28-00:59Three pushes (SHAs 6a0061a4, ec04e3d3, b1c061af), each triggering bundle-size.yml via pull_request_target
May 11, 01:05Branch deleted (first attempt abandoned or completed)
May 11, 10:46Branch re-created (second attempt)
May 11, 10:49PR #7378 opened as “WIP: simplify history build” (draft)
May 11, 10:49-11:11Four more pushes (SHAs 07b05e73, 97849378, dbdecb48, 65bf499d), each re-poisoning the cache
May 11, 11:31Final force-push to b1c061af (HEAD of main), PR closed, branch deleted. PR now shows 0 commits, 0 changes
May 11, 19:15Queued Release workflow job starts, restores poisoned cache
May 11, 19:20Poisoned cache payload executes, exfiltrates GitHub OAuth token
May 11, 19:50Attacker 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:WjJodlgwaE9NVGs0YTBzMlRsQlFaSFpHTjNwbGVHUktkVzV4

Double base64 decoding reveals a GitHub OAuth token:

Terminal window
$ echo "WjJodlgwaE9NVGs0YTBzMlRsQlFaSFpHTjNwbGVHUktkVzV4" | base64 -d | base64 -d
gho_HN198kK6NPPdvF7zexdJunq

The 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:

  1. May 11, 22:02 UTC: Tanner Linsley changed bundle-size.yml from pull_request_target to pull_request (commit 5d92d5ae, commit 3ee179f0)
  2. May 11, 22:57 UTC: Cache key busted by appending -v2 suffix in TanStack/config (PR #381)
  3. 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:

  1. Does any pull_request_target workflow check out PR code (github.event.pull_request.head.sha or refs/pull/*/merge)?
  2. Does the checked-out code path include actions/cache, actions/setup-node with caching, or any other cache-writing action?
  3. 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 Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

noon-contracts npm Package: DeFi Supply Chain RAT

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,...

SafeDep Team
Background
SafeDep Logo

Ship Code.

Not Malware.

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