high · 7.5CVE-2026-55487Jun 26, 2026

CVE-2026-55487: pnpm allowBuilds Build Policy Identity Spoof

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A flaw in how pnpm normalized dependency identifiers let an attacker append a fake peer suffix to a git, tarball, or URL dependency and have it pass as an already-approved build, running arbitrary lif

Packagepnpm
Ecosystemnpm
Affected< 10.34.2
Fixed in10.34.2

The problem

pnpm's allowBuilds policy is the primary defense against unauthorized lifecycle script execution for opaque dependency sources (git, tarball, file, URL). Before 10.34.2, the build-policy module ran all dependency identifiers through the same peer-suffix normalizer used for registry packages.

For registry packages, stripping parenthesized peer suffixes is correct. For opaque locators, it creates a collision: any attacker-controlled locator whose suffix stripped to the same base string as an already-approved one would pass the policy check and execute its postinstall/preinstall scripts.

A second parser bug in the Rust (pacquet) implementation also misclassified opaque URLs ending in a semver-looking fragment as registry packages, creating a third collision path.

Proof of concept

bash
# pnpm-workspace.yaml on the victim's machine (legitimately approved)
allowBuilds:
  foo@https://trusted-host.example/pkg.tgz: true

# Attacker ships a package.json that pulls in the malicious tarball
# as a dependency. The lockfile depPath for the evil dep becomes:
#   foo@https://trusted-host.example/pkg.tgz(evil-peer@1.0.0)
#
# Before the fix, the policy module stripped the parenthesized
# suffix from BOTH keys before comparing, so:
#   normalize("foo@https://trusted-host.example/pkg.tgz(evil-peer@1.0.0)")
#     => "foo@https://trusted-host.example/pkg.tgz"
# matched the approved entry and the lifecycle script ran.
#
# Second form (Rust / pacquet parser misclassification):
#   foo@https://trusted-host.example/pkg@1.0.0(good-peer@1)
#   foo@https://trusted-host.example/pkg@1.0.0(evil-peer@1)
# Both collapsed to the same base because the parser selected the
# final '@' and treated the opaque URL as a registry dep.
#
# Third form (source-only semver-tail collision):
#   Approval for  https://trusted-host.example/pkg@1.0.0
#   also matched  https://trusted-host.example/pkg@1.0.0(evil)

# Minimal reproduction (TypeScript policy layer, pre-patch):
# isAllowedBuild({
#   depPath: 'foo@https://trusted-host.example/pkg.tgz(evil-peer@1.0.0)',
#   allowBuilds: { 'foo@https://trusted-host.example/pkg.tgz': true }
# })
# => true   (should be false)

The root cause is CWE-346 / CWE-693 / CWE-829: the policy module applied peer-suffix stripping unconditionally, treating opaque locators the same as registry locators. For registry packages, a locator like foo@1.0.0(react@18) is legitimately the same package regardless of its peer context, so normalization is safe.

For opaque sources, the full locator string is the identity, and any parenthesized text is uncontrolled attacker input, not a peer context.

The patch in building/policy/src/index.ts and the parallel Rust path in pacquet/crates/package-manager/src/build_modules.rs now detect whether a depPath is a registry identity (name@semver, with optional patch hash) or an opaque identity. Only registry identities are normalized; opaque identities are matched byte-for-byte.

The Rust parser was also tightened to prevent the extra-'@' misclassification that made semver-tailed URLs look like registry deps.

The fix

Upgrade to pnpm 10.34.2 (patch commit 14bceb1e0b2a71f4f670774db261feb03f38ec23) or pnpm 11.5.3 (patch commit bf1b731ee6c0ea98709e671ff0f46bf654480ab8). After upgrading, any opaque dependency previously approved via a normalized key must be re-approved using the exact peer-suffix-free depPath shown in pnpm's ignored-build output.

Registry package names in allowBuilds continue to work as before.

Reporter not attributed.

References: [1][2][3][4][5][6]