Skip to content

Build1 publisher3 min readPublished

A predictable pnpm cache key carried fork code into TanStack's npm release workflow

TanStack's postmortem says 84 malicious versions went out across 42 packages on 2026-05-11 with no npm token stolen, because a release job restored a cache that an untrusted pull_request_target build had written.

The Engineer · Build desk

Photograph accompanying A predictable pnpm cache key carried fork code into TanStack's npm release workflow
Photo: endorlabs.com

What happened

  • TanStack says an attacker published 84 malicious versions across 42 @tanstack/* npm packages on 2026-05-11 between 19:20 and 19:26 UTC, chaining pull_request_target, Actions cache poisoning and OIDC theft from runner memory.
  • No npm tokens were stolen in the incident and the npm publish workflow itself was not compromised, according to the postmortem.
  • External researcher ashishkurmi, working for StepSecurity, reported the malicious versions publicly within 20 to 26 minutes of publication, depending on the batch.
  • TanStack recommends that anyone who installed an affected version that day rotate AWS, GCP, Kubernetes, Vault, GitHub, npm and SSH credentials reachable from the install host.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Any repository that runs fork code under pull_request_target has to decide whether its cache key namespace is a trust boundary, including caches pulled in transitively by a shared setup action nobody re-reads.
  • exposure The reachable surface is every install host, not just laptops: CI runners that installed an affected version ran the same prepare script and fall inside the same rotation advice.
  • constraint OIDC trusted publishing removes the stolen-token failure mode without protecting the workflow that holds id-token: write, so hardening has to cover what that job restores before it publishes.
  • precedent A cross-boundary cache issue documented two years earlier has now produced a published compromise. Maintainers own the mitigation.

The workflow author had already thought about this attack. bundle-size.yml kept the benchmark job separate from the job that comments on pull requests, and the YAML carried a note about keeping benchmark-pr "untrusted with read-only permissions" [10]. Read-only permissions constrain what the job's token can do to the repository. The job still had a cache writer in it, because the setup step is TanStack/config/.github/setup@main, which transitively calls actions/cache@v5 [9]. The cache action reaches the untrusted job only through that composite action.

Two lines in the same file supply the code to run. The trigger is pull_request_target, and the checkout is pinned to `ref: refs/pull/${{ github.event.pull_request.number }}/merge`, so fork-controlled source is built by `pnpm nx run @benchmarks/bundle-size:build` inside the base repository's trigger context [8].

Then the key. The attacker's vite_setup.mjs wrote into the pnpm store under `Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}`, the key the legitimate release.yml computes and looks up [11]. That key is a hash of a file anyone can read in the repository. When benchmark-pr ended, the cache post-step saved the poisoned store under it, and the next push to main had release.yml restore the entry at its Setup Tools step, "entirely as designed" in the postmortem's words [12]. release.yml declares `id-token: write` because npm OIDC trusted publishing needs it [15]. No credential had to leave the machine: TanStack says the OIDC token was extracted from the runner process's memory at runtime, and that no npm tokens were stolen [1][2].

On the consumer side the chain is short. npm resolves a malicious optionalDependencies entry, fetches an orphan payload commit from the fork network, runs its prepare lifecycle script, and executes a roughly 2.3 MB obfuscated router_init.js [16]. That happens during install, on a developer machine or a CI runner. TanStack says the install host must be treated as potentially compromised [17].

For this to land somewhere else, three conditions have to hold at once, and the postmortem says each link is necessary and none alone sufficient [18]:

1. a workflow triggered by pull_request_target that checks out the merge ref and executes fork code; 2. a cache action inside that job writing to a key namespace trusted workflows read; 3. a release workflow that restores that key and declares id-token: write.

StepSecurity's issue #7383 landed at 19:46 UTC, 26 minutes after the first publish at 19:20 [5]. The postmortem's "20 to 26 minutes" detection window is one wall-clock moment seen from two batches: 19:46 minus 19:26 is 20, and 19:46 minus 19:20 is 26 [22]. Inside that six-minute publish window the attacker pushed 84 versions, about 14 a minute [21]. The external researcher credited is ashishkurmi, working for StepSecurity [4].

TanStack wrote of the cache-crossing class: "It's not a TanStack-specific bug; it's a known GitHub Actions design issue that requires conscious mitigation." [13] The postmortem credits Adnan Khan with documenting the class in 2024 [14]. TanStack issued an all-clear on 2026-05-15 after a three-day sweep, and says every currently available published version of every TanStack package, Router and Start included, is safe to install [19].

What to watch

  • Whether npm pulls the tarballs for all 84 versions from the registry, beyond TanStack's deprecations.
  • What TanStack's companion hardening post changes: dropping pull_request_target, scoping the cache, or both.
  • Whether GitHub changes actions/cache defaults so fork-triggered jobs cannot write keys that trusted workflows restore.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories