---
title: "Cache Poisoning Through pull_request_target: The TanStack Incident"
description: "A GitHub user opened a PR against TanStack Router from a fork, poisoned the shared pnpm cache through a pull_request_target workflow, then force-pushed the branch clean. When the release pipeline restored the poisoned cache, the payload executed. Full attack chain analysis with timeline, IOCs, and remediation."
url: "https://safedep.io/blog/tanstack-github-actions-cache-poisoning"
author: "SafeDep Team"
published: "2026-05-13"
---

# Cache Poisoning Through pull_request_target: The TanStack Incident

## TL;DR

On May 11, 2026, a GitHub user `zblgg` [opened PR #7378](https://github.com/TanStack/router/pull/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`](https://github.com/zblgg)
- Fork repository: `zblgg/configuration` (fork of `TanStack/router`, created 2026-05-10)
- PR: [TanStack/router#7378](https://github.com/TanStack/router/pull/7378), branch `fix/history-package`
- Compromised Actions run: [25613093674/job/75429692202](https://github.com/TanStack/router/actions/runs/25613093674/job/75429692202)
- Token drop commit: [`8542572e1a36`](https://github.com/zblgg/configuration/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`](https://github.com/TanStack/router/blob/d296252f7324/.github/workflows/bundle-size.yml) workflow used `pull_request_target` as its trigger. The workflow comment acknowledged the trust boundary concern:

```yaml
# .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:

```yaml
# benchmark-pr job
steps:
  - uses: actions/checkout@v6.0.2
    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`](https://github.com/TanStack/config/blob/d8cef73c4209/.github/setup/action.yml) included `actions/cache` for the pnpm store.

```yaml
# TanStack/config/.github/setup/action.yml (pre-incident)
- name: Setup pnpm cache
  uses: actions/cache@v5.0.4
  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`](https://github.com/TanStack/router/blob/d296252f7324/.github/workflows/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**](https://api.github.com/repos/zblgg/configuration)                            | Fork `zblgg/configuration` created from `TanStack/router`                                                           |
| [**May 11, 00:26**](https://api.github.com/repos/zblgg/configuration/events)                     | Branch `fix/history-package` created                                                                                |
| [**May 11, 00:28-00:59**](https://api.github.com/repos/zblgg/configuration/events)               | Three pushes (SHAs `6a0061a4`, `ec04e3d3`, `b1c061af`), each triggering `bundle-size.yml` via `pull_request_target` |
| [**May 11, 01:05**](https://api.github.com/repos/zblgg/configuration/events)                     | Branch deleted (first attempt abandoned or completed)                                                               |
| [**May 11, 10:46**](https://api.github.com/repos/zblgg/configuration/events)                     | Branch re-created (second attempt)                                                                                  |
| [**May 11, 10:49**](https://github.com/TanStack/router/pull/7378)                                | PR #7378 opened as "WIP: simplify history build" (draft)                                                            |
| [**May 11, 10:49-11:11**](https://api.github.com/repos/TanStack/router/actions/runs?actor=zblgg) | Four more pushes (SHAs `07b05e73`, `97849378`, `dbdecb48`, `65bf499d`), each re-poisoning the cache                 |
| [**May 11, 11:31**](https://api.github.com/repos/zblgg/configuration/events)                     | Final force-push to `b1c061af` (HEAD of `main`), PR closed, branch deleted. PR now shows 0 commits, 0 changes       |
| [**May 11, 19:15**](https://github.com/TanStack/router/actions/runs/25613093674/job/75429692202) | Queued Release workflow job starts, restores poisoned cache                                                         |
| [**May 11, 19:20**](https://github.com/TanStack/router/actions/runs/25613093674/job/75429692202) | Poisoned cache payload executes, exfiltrates GitHub OAuth token                                                     |
| [**May 11, 19:50**](https://github.com/zblgg/configuration/commit/8542572e1a36)                  | 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](https://github.com/zblgg/configuration/commit/8542572e1a36) (0 files changed). The commit message:

```
IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner:WjJodlgwaE9NVGs0YTBzMlRsQlFaSFpHTjNwbGVHUktkVzV4
```

Double base64 decoding reveals a GitHub OAuth token:

```bash
$ 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](https://github.com/TanStack/router/commit/5d92d5aea302), [commit 3ee179f0](https://github.com/TanStack/router/commit/3ee179f0d997))
2. **May 11, 22:57 UTC**: Cache key busted by appending `-v2` suffix in `TanStack/config` ([PR #381](https://github.com/TanStack/config/commit/d5a7dccc53c5))
3. **May 12, 08:12 UTC**: Caching entirely disabled in the shared setup action ([PR #382](https://github.com/TanStack/config/commit/47d572f3bc5e))

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

- [TanStack/router PR #7378](https://github.com/TanStack/router/pull/7378) (the malicious PR)
- [Compromised Actions run](https://github.com/TanStack/router/actions/runs/25613093674/job/75429692202)
- [TanStack/config cache remediation](https://github.com/TanStack/config/commit/47d572f3bc5e)
- [GitHub Docs: Keeping your GitHub Actions and workflows secure](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions)
- [GitHub Security Lab: Pwn Request vulnerabilities](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/)
- [Adnan Khan (@adnanthekhan) on the TanStack incident](https://x.com/adnanthekhan/status/2053950558705258691)
