Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bootstrap-download-reads-npmrc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pacquet": patch
---

When the `pnpm` package has to download its native binary on first run, it now uses the registry and credentials from `.npmrc` and from the `npm_config_registry` and `pnpm_config_registry` environment variables. `COREPACK_NPM_REGISTRY` still takes precedence. A project `.npmrc` is not read when `COREPACK_INTEGRITY_KEYS` turns off the signature check [#16655](https://github.com/pnpm/pnpm/issues/16655).
191 changes: 180 additions & 11 deletions pnpm/npm/pnpm/bin/pnpm.mjs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Environment registry override loses to project config

When a project sets @pnpm:registry, registryAccess ignores an explicit registry environment override. First-run downloads use the project registry even when the operator supplies another registry.

(Refers to this code)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Nested projects lose their registry

From a subdirectory of a standalone project, projectDir reads only that subdirectory's .npmrc. The project-root registry is ignored, so the native binary downloads from the wrong registry or fails.

(Refers to this code)

Learn more

The wrapper runs before the native CLI can load its configuration. The CLI finds a project's local prefix using package markers before reading .npmrc (findLocalPrefix, readNpmrcSources). Without a workspace file, this bootstrap search always falls back to the current directory, even when an ancestor contains package.json.

Example: /work/app/package.json and /work/app/.npmrc name an internal registry. A first run from /work/app/src reads /work/app/src/.npmrc and contacts npmjs instead.

Recommended fix: Use the CLI's local-prefix markers for standalone projects while retaining workspace-root precedence where a workspace file exists. Test a nested working directory.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Environment-only registry authentication fails

When registry credentials exist only in URL-scoped environment variables, readUserNpmrc supplies none to the bootstrap. Private registries reject the first-run native binary download despite credentials available to normal pnpm installs.

(Refers to this code)

Learn more

The native CLI accepts URL-scoped npm_config_//host/:_authToken and pnpm_config_//host/:_authToken values as trusted credential sources (from_url_scoped_env). The bootstrap reads only .npmrc files when Corepack's registry override is absent. This leaves environment-only private-registry setups unable to download the binary.

Example: pnpm_config_registry=https://packages.example.test/ and pnpm_config_//packages.example.test/:_authToken=token are set, but no .npmrc exists. Normal pnpm can authenticate; the first-run download sends no authorization header.

Recommended fix: Include URL-scoped credential environment variables in npmrcHeaders with the native reader's priority and URI scoping. Exercise an environment-only private registry.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Saved login credentials are unavailable during bootstrap

A private registry configured through pnpm login can have credentials only in global auth.ini. The wrapper reads .npmrc instead, so its first download gets no token. The native reader includes auth.ini.

(Refers to this code)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 No Execution Profile for current head

The PR description has no Execution Profile for be7278d. Its first-run network destinations cannot be checked against a current-head execution record.

(Refers to this code)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Project registry can claim trusted credentials

When a project selects a registry matching a user-level credential scope, registryAccess sends that credential to the project-selected host. A malicious project can use an existing broad credential scope to expose its token.

(Refers to this code)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
//
// The download itself is `get-pnpm`, the package behind https://get.pnpm.io,
// which already knows how to verify one; it travels in that same `dist/`
// payload. What is left here is Corepack's environment, which it does not know:
// where to download from, what credentials to use, and whose signature to trust.
// payload. What is left here is what it does not know: where to download from,
// what credentials to use, and whose signature to trust.
import { Buffer } from 'node:buffer'
import { spawnSync } from 'node:child_process'
import console from 'node:console'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
Expand Down Expand Up @@ -78,13 +79,13 @@ async function nativeBinary () {
const { downloadPnpmExecutable } = await import(GET_PNPM).catch((err) => {
fail(`This copy of the pnpm package is missing the downloader it needs: ${err.message}`)
})
const signature = signaturePolicy()
try {
await downloadPnpmExecutable({
version,
registry: process.env.COREPACK_NPM_REGISTRY || DEFAULT_REGISTRY,
destPath: DOWNLOADED_BINARY,
headers: registryHeaders(),
...signaturePolicy(),
...registryAccess({ readProjectNpmrc: signature.verifySignature !== false }),
...signature,
})
} catch (err) {
fail(`Could not download the pnpm ${version} binary: ${err.message}`)
Expand All @@ -93,22 +94,188 @@ async function nativeBinary () {
}

/**
* Credentials for the registry, read the way Corepack reads its own. `get-pnpm`
* keeps them on that registry's origin, so a download host it names never
* receives them.
* The registry to download from, and the headers to send it. Corepack's
* `COREPACK_NPM_REGISTRY` wins. Otherwise npm's sources decide, highest first:
* the environment, the project `.npmrc`, the user `.npmrc`.
*
* The project `.npmrc` is read only with `readProjectNpmrc`, which the caller
* passes while the download is checked against npm's signature. The signature
* pins the bytes, so a repository picks only where they come from. Unchecked,
* it would pick the binary itself, which is kept next to this wrapper and run
* in every later project (GHSA-j2hc-m6cf-6jm8). `get-pnpm` keeps the headers on
* the registry's origin, so a download host it names never receives them.
*/
function registryHeaders () {
function registryAccess ({ readProjectNpmrc }) {
const corepackRegistry = process.env.COREPACK_NPM_REGISTRY
if (corepackRegistry) {
return { registry: corepackRegistry, headers: corepackHeaders() }
}
const npmrc = readUserNpmrc()
if (readProjectNpmrc) {
Object.assign(npmrc, readNpmrc(path.join(projectDir(), '.npmrc'), projectEntry))
}
const registry = npmrc['@pnpm:registry'] || settingFromEnv('registry') || npmrc.registry
if (!registry) {
return { registry: DEFAULT_REGISTRY, headers: corepackHeaders() }
}
return { registry, headers: npmrcHeaders(npmrc, registry) }
}

/** Credentials for Corepack's registry, read the way Corepack reads its own. */
function corepackHeaders () {
const { COREPACK_NPM_TOKEN, COREPACK_NPM_USERNAME, COREPACK_NPM_PASSWORD } = process.env
if (COREPACK_NPM_TOKEN) {
return { authorization: `Bearer ${COREPACK_NPM_TOKEN}` }
}
if (COREPACK_NPM_USERNAME && COREPACK_NPM_PASSWORD) {
const credentials = Buffer.from(`${COREPACK_NPM_USERNAME}:${COREPACK_NPM_PASSWORD}`, 'utf8')
return { authorization: `Basic ${credentials.toString('base64')}` }
return { authorization: `Basic ${base64(`${COREPACK_NPM_USERNAME}:${COREPACK_NPM_PASSWORD}`)}` }
}
return undefined
}

function settingFromEnv (key) {
const upper = key.toUpperCase()
const { env } = process
return env[`pnpm_config_${key}`] || env[`PNPM_CONFIG_${upper}`] ||
env[`npm_config_${key}`] || env[`NPM_CONFIG_${upper}`] || undefined
}

/** The user `.npmrc`, with `${VAR}` placeholders expanded. */
function readUserNpmrc () {
return readNpmrc(
settingFromEnv('userconfig') || path.join(os.homedir(), '.npmrc'),
(key, value) => [expandEnv(key), expandEnv(value)]
)
}

/**
* A project `.npmrc` entry, which is not expanded. One with a `${VAR}`
* placeholder in a registry setting or a credential is dropped, as pnpm drops
* it: a repository must not route the user's environment to a registry.
*/
function projectEntry (key, value, file) {
if (!key.includes('${') && !value.includes('${')) {
return [key, value]
}
if (key.startsWith('//') || key === 'registry' || key.endsWith(':registry')) {
console.error(`Ignored ${key} from ${file}: a project .npmrc cannot use environment variables in registry settings or credentials.`)
}
return undefined
}

/** Where pnpm reads the project `.npmrc`: the workspace root, or else the current directory. */
function projectDir () {
const cwd = process.cwd()
for (let dir = cwd; ; dir = path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
return dir
}
if (path.dirname(dir) === dir) {
return cwd
}
}
}

/**
* The top-level settings of the `.npmrc` at `file`, each passed through
* `parseEntry`, which returns the `[key, value]` to keep or `undefined`.
*/
function readNpmrc (file, parseEntry) {
const settings = Object.create(null)
let text
try {
text = fs.readFileSync(file, 'utf8')
} catch (err) {
if (err.code !== 'ENOENT' && err.code !== 'EISDIR') {
console.error(`Could not read ${file}, so its settings are not used: ${err.message}`)
}
return settings
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
// Settings under a `[section]` are not top-level ones.
if (line.startsWith('[')) {
break
}
const separator = line.indexOf('=')
if (separator === -1 || line.startsWith(';') || line.startsWith('#')) {
continue
}
const entry = parseEntry(line.slice(0, separator).trim(), unquote(line.slice(separator + 1).trim()), file)
if (entry != null) {
settings[entry[0]] = entry[1]
}
}
return settings
}

function unquote (value) {
const quoted = value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]
return quoted ? value.slice(1, -1) : value
}

function expandEnv (value) {
return value.replace(/\$\{([^${}]+)\}/g, (_, name) => process.env[name] ?? '')
}

/**
* The credentials the `.npmrc` holds for `registry`, looked up as pnpm looks
* them up for an `@pnpm` package: the `@pnpm` scope's own credentials first,
* then the registry-wide ones. Each is the longest `//host[:port]/path/`
* prefix of the registry URL that has any, then the same without the port.
* They are sent only over HTTPS or to the local machine.
*/
function npmrcHeaders (npmrc, registry) {
const url = new URL(registry.endsWith('/') ? registry : `${registry}/`)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url.hostname))) {
return undefined
}
const prefixes = []
for (const host of new Set([url.host, url.hostname])) {
for (let pathname = url.pathname; ; pathname = pathname.slice(0, pathname.lastIndexOf('/', pathname.length - 2) + 1)) {
prefixes.push(`//${host}${pathname}`)
if (pathname === '/') {
break
}
}
}
for (const scope of [':@pnpm', '']) {
for (const prefix of prefixes) {
const authorization = npmrcAuthorization(npmrc, `${prefix}${scope}`)
if (authorization != null) {
return { authorization }
}
}
}
return undefined
}

function isLoopback (hostname) {
return hostname === 'localhost' || hostname === '[::1]' ||
(net.isIPv4(hostname) && hostname.startsWith('127.'))
}

function npmrcAuthorization (npmrc, prefix) {
const token = npmrc[`${prefix}:_authToken`]
if (token) {
return `Bearer ${token}`
}
const auth = npmrc[`${prefix}:_auth`]
if (auth) {
return `Basic ${auth}`
}
const username = npmrc[`${prefix}:username`]
const password = npmrc[`${prefix}:_password`]
if (username && password) {
return `Basic ${base64(`${username}:${Buffer.from(password, 'base64').toString('utf8')}`)}`
}
return undefined
}

function base64 (text) {
return Buffer.from(text, 'utf8').toString('base64')
}

/**
* Whose signature over the download to trust, following `COREPACK_INTEGRITY_KEYS`
* exactly as Corepack does: npm's own keys when it is unset, the keys it names
Expand Down Expand Up @@ -145,3 +312,5 @@ function fail (message) {
console.error(message)
process.exit(1)
}

// Recorded by the fork-only Garnet Bootstrap Routing workflow.
Loading
Loading