-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## bundle-size.yml had no publish authority\n\n`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](/posts/tj-actions-mutable-tags-were-always-a-lie) was the same ecosystem at a different layer; CVE-2026-45321 lives one layer down.\n\nWhat `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.\n\nThe 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.\n\n`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.\n\nThey shared a content-addressable cache.\n\n## GitHub Actions caches are not partitioned by trust context\n\nGitHub 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.\n\nA `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.\n\nTanStack'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.\n\nThe 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.\n\nThe 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`.\n\n## release.yml had id-token: write\n\n`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`.\n\n`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.\n\nThe 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//mem` to extract the OIDC token. `/proc//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.\n\nWith 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.\n\n## The cache was the bridge\n\nThe 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.\n\n`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.\n\nThe 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.\n\nThis is the [trust-inversion](/patterns/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.\n\n## Trusted publishers redefined the maintainer account. This is what compromising the new shape looks like.\n\nnpm 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.\n\nCVE-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.\n\nThe 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.\n\nThis is [maintainer-account-compromise](/patterns/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](/posts/axios-sapphire-sleet-70-million-installs) was the previous-shape exemplar: same outcome, different layer, the same eight-hundred-pound assumption that \"this publish came from the maintainer\" still holds.\n\n## The propagation payload is the worm shape, ported\n\nThis is the [self-propagating-supply-chain](/patterns/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.\n\n[Shai-Hulud established the worm shape on npm in 2025](/posts/shai-hulud-the-npm-worm); 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.\n\n## The detection signature is an optionalDependency that resolves nowhere\n\nThe fingerprint the advisory ships is the `optionalDependencies` entry every malicious tarball carries at the top level of its `package.json`:\n\n```json\n\"optionalDependencies\": {\n \"@tanstack/setup\": \"github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c\"\n}\n```\n\nThe 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.\n\nThe 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.\n\nNone 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.\n\nPoC: [renewablehacking/CVE-2026-45321-Tanstack](https://github.com/renewablehacking/CVE-2026-45321-Tanstack)","closing_line":"release.yml signed for the cache. bundle-size.yml wrote it.","hook_md":"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.","post_id":500,"slug":"tanstack-cve-2026-45321-bundle-size-poisoned-release-restored","title":"CVE-2026-45321: TanStack's bundle-size.yml Poisoned the Cache. release.yml Restored It.","type":"initial","unreadable_sentence":"The cache service treats every write to a key as authoritative and every read of a key as legitimate."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahdA5AAKCRDeZjl4jgkQ JoADAP95Vw2LK898eCRajALdKsXa7IO5rIVzBvXywpe9ysyvJAEAkY0fUPT21TJc wmYm5jbJ5v86soV1vuLgC3FKYtLkOgU= =AMZN -----END PGP SIGNATURE-----