TanStack's compromised workflow was bundle-size.yml. The workflow that published 84 malicious versions across 42 @tanstack/* packages on May 11 was release.yml. They never ran in the same job, on the same runner, with the same secrets, or in the same trust context. The pnpm cache ran in both.
CVE-2026-45321: TanStack's bundle-size.yml Poisoned the Cache. release.yml Restored It.
patterns
cve
proof of concept
bundle-size.yml had no publish authority
bundle-size.yml is TanStack's benchmark workflow. It runs pnpm nx run @benchmarks/bundle-size:build against incoming pull requests so reviewers can see how the PR changes the shipped bundle size against main. Its trigger is on: pull_request_target. That phrase is the carrier in every modern GitHub Actions supply-chain compromise of this shape: the workflow runs in the trusted context of the base repository, with access to the base's secrets and the base's GitHub Actions cache, while it checks out the fork's code into the runner's working directory. The two facts compose into "fork code executing in base trust," which is the canonical Pwn Request pattern. tj-actions was the same ecosystem at a different layer; CVE-2026-45321 lives one layer down.
What bundle-size.yml did not have was id-token: write in its permissions block. It did not have any NODE_AUTH_TOKEN. It did not call npm publish. The workflow's compromise blast radius, considered as a single isolated event, was a narrow read: whatever secrets bundle-size.yml happened to enumerate, plus the runner's environment.
The workflow that did the publishing was release.yml. release.yml runs on: push to main. It declares id-token: write because TanStack publishes to npm through the trusted-publishers OIDC flow. On every release, release.yml mints an OIDC token at token.actions.githubusercontent.com, exchanges it at registry.npmjs.org/-/npm/v1/oidc/exchange for a short-lived publish credential, and uploads the tarballs nx release produces.
bundle-size.yml could not, on its own runner, call registry.npmjs.org with publish authority. release.yml could. The two workflows shared no secrets, no checkout context, no permission scopes, and no source-code paths.
They shared a content-addressable cache.
GitHub Actions caches are not partitioned by trust context
GitHub Actions provides a per-repository key-value cache via actions/cache@v4. The cache is content-addressable: a workflow declares a path and a key, the action restores the cache entry matching the key on entry, and saves the entry back to the key on exit. The action's documentation describes the cache scope as "the branch that the run is on," with the additional rule that caches saved on main are readable by every branch. There is no scope along the dimension that matters for this CVE. The action does not partition writes by which trust context produced them.
A pull_request-triggered run from a fork PR cannot save into the base-branch cache scope; GitHub's cache service rejects the write on permissions. A pull_request_target-triggered run can. The whole purpose of the trigger is that it runs as the base. From the cache service's perspective, bundle-size.yml running on a PR-triggered job is the base repository writing into its own cache, because the trigger is the trusted context. The cache service has no notion that the workflow's working directory was just populated from a fork.
TanStack's bundle-size.yml cached the pnpm content store at ~/.pnpm-store under a key derived from a hash of pnpm-lock.yaml. The cache size, per the runner logs at 11:29 UTC on May 11, was 1.1 GB. The restore key it computed from the base lockfile is the same key release.yml would later compute from the same base lockfile, because the PR did not change pnpm-lock.yaml. The cache the PR job wrote is the cache the next push to main read.
The attacker's contribution was a fork at github.com/zblgg/configuration and a single PR against TanStack/router. The PR added a roughly 30,000-line bundled JavaScript payload at packages/history/vite_setup.mjs, a path the package's install lifecycle invoked while pnpm ran setup. When bundle-size.yml's pnpm install step ran, the bundle ran. Its job was not to publish anything. Its job was to leave artifacts in ~/.pnpm-store that the next process to resolve the same dependency tree would execute. bundle-size.yml's actions/cache/save@v4 step at the end of the job uploaded ~/.pnpm-store to the GitHub Actions cache service under the base lockfile's key.
The 1.1 GB poisoned cache sat in the cache service from 11:29 UTC until 19:20 UTC. Eight hours and eleven minutes. The first push to main that triggered release.yml in that window was the next legitimate release of @tanstack/react-router 1.169.5.
release.yml had id-token: write
release.yml's first step after checkout is pnpm install. The actions/cache/restore@v4 step preceding it pulled the poisoned ~/.pnpm-store from the cache service under the same lockfile-derived key. pnpm install resolved the dependency tree against the poisoned store, which executed the attacker's code in the release.yml process tree with the release.yml job's permissions. Those permissions, declared at the top of the file because TanStack uses trusted publishers, included id-token: write.
id-token: write is the scope that lets a workflow call the runner's OIDC token endpoint. The runner does not mint the JWT at job start. It mints it lazily, into the worker process's memory, the first time a step in the job dereferences ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (both environment variables the runner injects into every step). Once minted, the token persists in the Runner.Worker process memory until the job ends.
The attacker's code, running inside pnpm install, located the runner process via /proc/*/cmdline (every Linux process exposes its command line at that path), identified the Runner.Worker pid, and read /proc/<pid>/mem to extract the OIDC token. /proc/<pid>/mem is a regular file from a permissions perspective: any process running as the same uid as the target can read it, and pnpm install's subprocesses run as the runner's uid.
With a valid OIDC JWT in hand, the attacker's code POSTed it to registry.npmjs.org/-/npm/v1/oidc/exchange and received a short-lived publish credential bound to repo:TanStack/router:workflow:release.yml, the same subject npm trusted publishers had registered for legitimate TanStack releases. It then POSTed 42 tarballs to the registry in the first batch at 19:20:39 UTC, and a second batch of 42 at 19:26:14 UTC. Six minutes total. The legitimate nx release step, when it eventually ran, was racing.
The cache was the bridge
The popular framing of CVE-2026-45321 is "another pull_request_target Pwn Request." That framing is accurate as a description of bundle-size.yml's misconfiguration. It is not accurate as a description of how the attacker got publish authority.
bundle-size.yml had no publish authority. The attacker's code ran in bundle-size.yml at 11:11 UTC on May 11 and did not publish. It wrote into the GitHub Actions cache and exited. The publish came eight hours later, in a workflow the attacker never submitted code to, on a runner the attacker never reached interactively, with credentials the attacker's PR-triggered workflow never had access to.
The bridge was the cache. bundle-size.yml's write permission on the cache key was granted because pull_request_target runs as the base. release.yml's read of the same cache key was unconditional because the cache service does not record who wrote a key. The cache service treats every write to a key as authoritative and every read of a key as legitimate.
This is the trust-inversion shape at the layer of CI artifact caches. The trusted artifact is the cache itself: the canonical, content-addressed, integrity-hashed pnpm store that every workflow in the repository reads on install. The catalog's reading of trust inversion as "the tools and credentials that authorize access to your systems are now the attack surface" fits the cache directly. The cache is the tool that authorizes pnpm install to skip resolution and integrity checking. Once an attacker can write into it, the install's downstream consumers ratify whatever it now contains.
Trusted publishers redefined the maintainer account. This is what compromising the new shape looks like.
npm trusted publishers exist to eliminate the long-lived NPM_TOKEN from CI configs. The feature works as advertised: TanStack's release.yml did not contain an npm token, did not store one in GitHub secrets, did not have one accessible by name in any of its steps. The legitimate publish flow asks the runner for an OIDC token at the moment of publish, exchanges it for a credential at the registry, and discards both at job end.
CVE-2026-45321 published 84 malicious versions across 42 packages without an npm token, by reading the OIDC JWT from the runner's process memory before the legitimate step needed it. The token's blast radius, considered as a credential, is small: it expires inside fifteen minutes, it binds to one workflow subject, and it cannot be replayed after the job ends. Six minutes was enough.
The trusted-publisher feature did not fail. The trusted publisher's authentication path was the attack: the registered subject repo:TanStack/router:workflow:release.yml covers every run of release.yml on the main branch, including the one whose process memory was being read by an attacker's payload that pnpm had restored from cache. The npm registry validated the OIDC token correctly. It issued the credential to the correct workflow. It logged the publish against the correct subject. None of those steps had any way to know that the workflow was running attacker code from a poisoned store.
This is maintainer-account-compromise at the layer trusted publishers redefine "maintainer account" to be. The TanStack maintainer's npm credentials were never accessed. The maintainer's GitHub account was never accessed. The publish authority npm registered to repo:TanStack/router:workflow:release.yml was issued to repo:TanStack/router:workflow:release.yml, on a legitimate-looking run of release.yml on main, exactly as the registration prescribed. Trusted publishers moved publish authority off the maintainer's token and onto the workflow file's execution context. CVE-2026-45321 is what compromising that execution context looks like, and the catalog's Axios incident was the previous-shape exemplar: same outcome, different layer, the same eight-hundred-pound assumption that "this publish came from the maintainer" still holds.
The propagation payload is the worm shape, ported
This is the self-propagating-supply-chain extension of the same compromise. The malicious tarballs included a 2.3 MB obfuscated router_init.js whose stated job, per GHSA-g7cv-rxg3-hmpx, was to harvest AWS, GCP, Kubernetes, Vault, npm, GitHub, and SSH credentials from the install environment, exfiltrate them via the Session/Oxen messenger network (filev2.getsession.org, seed{1,2,3}.getsession.org), and then enumerate the installing developer's other npm packages and republish them with the same injection.
Shai-Hulud established the worm shape on npm in 2025; CVE-2026-45321 ports the same payload pattern onto trusted-publisher infrastructure. The propagation cost is unchanged: each downstream install that ran with publish capability for any package the maintainer owned was a fresh propagation event. The credential the worm rides is different. Shai-Hulud rode npm tokens stored in developer environments. CVE-2026-45321's payload rides whatever the install environment can mint, including, on a CI runner with id-token: write, another OIDC JWT exchangeable at registry.npmjs.org. npm's deprecation push at 21:03 UTC closed the window before the first round's harvest could complete its second round.
The detection signature is an optionalDependency that resolves nowhere
The fingerprint the advisory ships is the optionalDependencies entry every malicious tarball carries at the top level of its package.json:
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}The git ref points at a commit in the real tanstack/router repository. The hash does not resolve to a commit anyone pushed. npm's resolution of optionalDependencies is allowed to fail without aborting the parent install, so the entry never broke a downstream npm install even though the ref was never live. It is the only field in the malicious tarballs that is not also present in legitimate releases. The advisory's affected-version range is each package's two malicious versions between 19:20 and 19:26 UTC on May 11, 2026. The patched versions are point releases tagged within the next 36 hours.
The hardening PR TanStack landed after the incident does three things. It adds permissions: contents: read to bundle-size.yml so the workflow cannot write to the repository even if it executes attacker code. It adds a repository_owner guard so the workflow's expensive steps short-circuit when the PR is from a fork. It pins every uses: reference to a SHA so future @v4 mutations cannot silently swap action implementations.
None of these changes address the cache. The cache scope rules are GitHub's, not TanStack's; the partitioning that this CVE needed does not exist as a configuration knob. The hardening reduces the chance that a future fork PR will reach pnpm install in bundle-size.yml at all. It does not change the property that any future workflow which does reach pnpm install in a pull_request_target context will write into the same cache namespace that release.yml reads.
release.yml signed for the cache. bundle-size.yml wrote it.