How 84 Malicious TanStack Packages Hit npm in 6 Minutes
On May 11, 2026, an attacker published 84 malicious versions across 42 @tanstack/* packages in under 6 minutes. Not a typo. Here is the exact chain that made it possible. 42 @tanstack packages compromised via GitHub Actions cache poisoning and OIDC token extraction

This is not a story about a zero-day. Every single technique used here was documented publicly before the attack. The attacker recombined existing research, chained three known vulnerabilities, and walked right through the door that was already open.
The Three-Part Chain
No single vulnerability made this possible. Each one bridges a trust boundary the next one needs.
1. pull_request_target Pwn Request
bundle-size.yml used the pull_request_target event trigger. This trigger runs in the context of the base repository, not the fork. It has access to secrets, cache, and write permissions that pull_request does not.
The workflow checked out the fork's PR code and ran a build against it:
on:
pull_request_target:
paths: ['packages/**', 'benchmarks/**']
jobs:
benchmark-pr:
steps:
- uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- uses: TanStack/config/.github/setup@main
- run: pnpm nx run @benchmarks/bundle-size:build
That last run step executes arbitrary fork-controlled code with base-repo permissions. The author knew this was dangerous — there's a comment in the YAML noting the intent to keep benchmark-pr "untrusted with read-only permissions." The intent was right. The implementation missed something critical.
2. GitHub Actions Cache Poisoning
Setting permissions: contents: read does not prevent cache writes. actions/cache@v5 saves cache using a runner-internal token, not the workflow GITHUB_TOKEN. So the "read-only" workflow could still write to the shared pnpm cache.
The attacker's malicious vite_setup.mjs (smuggled into the PR as packages/history/vite_setup.mjs, ~30,000 lines of bundled JS) ran during the benchmark build and wrote a poisoned pnpm store to cache key:
Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11
That key is computed from hashFiles('**/pnpm-lock.yaml'). The attacker crafted the cache entry to match exactly what release.yml would look up on the next push to main. Cache scope in GitHub Actions is per-repo, shared between pull_request_target runs and pushes. The PR running on fork code wrote to the same cache namespace that production workflows use.
Cache poisoning as a class was documented by Adnan Khan in May 2024. This is not a new attack surface.
3. OIDC Token Extraction from Runner Memory
release.yml declared id-token: write for npm's OIDC trusted-publisher binding. When the poisoned pnpm store was restored on the runner, attacker-controlled binaries were now on disk. They located the GitHub Actions process, read and to dump worker memory, and extracted the OIDC token the runner had minted in memory.
Comments
Leave a comment
Lovable Leaks Source Code: The $6.6B BOLA Vulnerability
An 8 million user platform ignored a critical BOLA vulnerability for 48 days. How a $6.6B AI app builder leaked source code, credentials, and user data.
Kubernetes vs Docker: Stop Comparing the Wrong Things
Docker builds containers. Kubernetes runs them at scale. They're not rivals and picking the wrong mental model for each costs you months of overhead.
Building a Personal MCP Server for Claude
Stop copy-pasting your bio. Build a custom Model Context Protocol (MCP) server in TypeScript to give Claude native access to your projects, resume, and personal context.
Tagged