fix(config/reader): pin unscoped per-registry settings to their source's registry at load time (#11953)

* fix(config/reader): drop user-level default auth when workspace overrides registry

When a workspace `.npmrc` overrides `registry=` to a different value than the
user's `~/.npmrc` or `~/.config/pnpm/auth.ini` would have set, do not bind
unscoped/default credentials (`_authToken`, `_auth`, `username`/`_password`)
from the user-level config to the workspace-selected registry. The previous
behavior leaked user-trusted credentials to whatever registry an untrusted
workspace `.npmrc` pointed at. Reported by JUNYI LIU.

* chore(cspell): allow JUNYI in changeset and tests

* fix(config/reader): also defend when pnpm-workspace.yaml overrides registry

Move the rebind defense to after all config layers (CLI, env vars,
pnpm-workspace.yaml, .npmrc) have settled. Compare the final resolved
default registry against what the user-level config alone would produce,
and skip the check entirely if the user requested a registry via CLI/env
themselves.

* feat(config/reader): deprecate unscoped authentication credentials

Emit a per-file warning whenever an .npmrc or auth.ini contains an
unscoped auth value (_authToken, _auth, username, _password,
tokenHelper). URL-scoped tokens have been npm's recommended pattern
since npm@9, and unscoped credentials are slated for removal in a
future major. The warning fires independently of whether the rebind
defense rejects the credentials, so users see the deprecation even when
their setup happens to be safe today.

* refactor(config/reader): rescope unscoped credentials at load time instead of detecting rebinds post-merge

Each .npmrc / auth.ini / CLI source's unscoped credential keys
(_authToken, _auth, username, _password, tokenHelper) are rewritten to
their URL-scoped equivalent during load, using the same source's
registry= value (or the npmjs default if it declares none). A later
layer overriding registry= can no longer rebind a credential to its own
registry — the credential is already pinned to the URL its author
intended.

This removes the post-merge source-tracking defense and replaces it
with the simpler per-source normalization. Each rescope emits a
deprecation warning so users migrate to writing the URL-scoped form
directly.

* refactor(network/auth-header): drop empty-string default-registry slot

After load-time rescoping, no source can populate configByUri[''] —
every credential is either URL-scoped from the start or rewritten to
the URL-scoped form during the .npmrc / auth.ini / CLI parse. The
runtime fallback that re-keyed configByUri[''] onto the merged default
registry, and the publish-side fallback that read it, are both dead
code.

Removed:
- empty-string handling in getAuthHeadersFromCreds, including its
  defaultRegistry parameter
- defaultRegistry parameter from createGetAuthHeaderByURI
- the corresponding dedicated unit test
- the configByUri['']?.creds fallback in publishPackedPkg.ts
- empty-key assertions in config/reader tests

Updated all ~16 call sites of createGetAuthHeaderByURI to drop the now
unused second argument.

* feat(config/reader): extend per-source rescoping to client TLS cert/key

The same trust-boundary issue that affected unscoped credentials applies
to client TLS settings: an unscoped cert=/key= would be presented to
whatever registry the merged config settles on, even if a later layer
(workspace .npmrc, pnpm-workspace.yaml, CLI flag) overrode it. The
existing rescope helper now also rewrites unscoped `cert` and `key`
to their URL-scoped form, pinning them to the registry their author
named in the same source.

`ca`/`cafile` are intentionally left unscoped: they're trust anchors,
not credentials, and corporate MITM-proxy setups depend on them
applying to every HTTPS request. The default-registry override can't
weaponize an unscoped CA — the attacker would need a cert signed by it.

`certfile`/`keyfile` (file-path variants) are not rescoped either:
`certfile` isn't read unscoped by pnpm today (asymmetric vs. `keyfile`
in NPM_AUTH_SETTINGS), and supporting only one of them would be
confusing. Users wanting the path form can write it URL-scoped
directly.

* chore(config/reader): remove dead unscoped `keyfile` allowlist entry

`keyfile` was listed in NPM_AUTH_SETTINGS so unscoped `keyfile=<path>`
passed the .npmrc filter and ended up in authConfig — but nothing in
the codebase ever read it from there. The dispatcher uses `opts.key`
(inline PEM) and `configByUri[host].tls.key` (URL-scoped path/inline
content), neither of which is populated from unscoped `keyfile=`.

`certfile` was already absent from the allowlist for the same reason,
so this also removes the asymmetry between the two file-path variants.
URL-scoped `//host/:certfile=...` and `//host/:keyfile=...` continue
to work via `tryParseSslKey` and are unaffected.

* test(network/auth-header): drop test for removed default-registry slot

This test exercised the configByUri[''] re-keying path that was
removed in the rescope-at-load refactor. With createGetAuthHeaderByURI
no longer accepting a defaultRegistry parameter and unscoped
credentials no longer reaching the merged config, the scenario the
test described is structurally unreachable.

* fix(config/reader): handle empty/invalid registry value in rescope

Two CI fixes:

1. When a source's `registry=` resolves to an empty string (e.g. an
   unresolved `${ENV_VAR}` placeholder), `new URL(...)` inside
   `nerfDart` throws. Guard the call with try/catch: drop the
   unscoped per-registry keys (a bare token has nowhere safe to bind)
   and emit a warning naming the offending source.

2. Update `.npmrc does not load pnpm settings` to expect the rescoped
   form of unscoped `_authToken`/`username` in `authConfig` — they
   now appear as `//registry.npmjs.org/:_authToken` etc. since the
   test's .npmrc declares no `registry=` of its own.

* chore(cspell): allow "rescoping"

* test(installing/deps-installer): drop "legacy way" auth test

This test passed credentials via the configByUri[''] empty-string slot,
which the auth-header layer re-keyed to the merged default registry at
request time. That slot was removed in the rescope-at-load refactor —
credentials are now always URL-scoped before they reach configByUri,
so the empty-key entry is unreachable from any code path.

The scenario the test covered (basicAuth via username/password) is
already exercised by the existing "installing a package that need
authentication, using password" test using the URL-scoped form.
This commit is contained in:
Zoltan Kochan
2026-05-26 16:46:50 +02:00
committed by GitHub
parent 0c5b66f1ea
commit a23956e3ab
30 changed files with 460 additions and 100 deletions

View File

@@ -0,0 +1,13 @@
---
"@pnpm/config.reader": patch
"@pnpm/network.auth-header": major
"pnpm": patch
---
Fix a credential disclosure issue where an unscoped `_authToken` (or `_auth`, or `username` + `_password`, or `tokenHelper`) defined in one source — `~/.npmrc`, `~/.config/pnpm/auth.ini`, a workspace `.npmrc`, CLI flags, etc. — would be sent as an `Authorization` header to whichever registry a different (potentially untrusted) source named. The same fix extends to client TLS credentials (`cert`, `key`) so they aren't presented to a registry their author didn't choose.
pnpm now rewrites each unscoped per-registry setting (`_authToken`, `_auth`, `username`, `_password`, `tokenHelper`, `cert`, `key`) to its URL-scoped form at load time, using the `registry=` value declared in the same source (or the npmjs default registry if the source declares none). A later layer overriding `registry=` therefore cannot pull an unscoped credential along, because it is already pinned to the URL its author intended. `ca`/`cafile` are intentionally not rescoped — they're trust anchors, not credentials, and corporate MITM-proxy setups rely on them applying globally.
Every rescope emits a deprecation warning telling the user where the setting was pinned and how to write it directly. npm has rejected unscoped credentials outright since `npm@9`, and pnpm intends to remove support in a future major release. To target a specific registry, write the setting URL-scoped (e.g. `//registry.example.com/:_authToken=...` or `//registry.example.com/:cert=...`).
`@pnpm/network.auth-header`: removed the `defaultRegistry` parameter from `createGetAuthHeaderByURI` and `getAuthHeadersFromCreds`. Now that credentials are URL-scoped at load time, the merged `configByUri` never contains the empty-string "default registry" placeholder slot, so re-keying it onto the merged default registry is no longer needed.

View File

@@ -37,6 +37,7 @@
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/config.env-replace": "catalog:",
"@pnpm/config.matcher": "workspace:*",
"@pnpm/config.nerf-dart": "catalog:",
"@pnpm/constants": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/hooks.pnpmfile": "workspace:*",

View File

@@ -38,7 +38,7 @@ import { isConfigFileKey } from './configFileKey.js'
import { extractAndRemoveDependencyBuildOptions, hasDependencyBuildOptions } from './dependencyBuildOptions.js'
import { getCacheDir, getConfigDir, getDataDir, getStateDir } from './dirs.js'
import { parseEnvVars } from './env.js'
import { getDefaultCreds, getNetworkConfigs } from './getNetworkConfigs.js'
import { getNetworkConfigs } from './getNetworkConfigs.js'
import { getOptionsFromPnpmSettings } from './getOptionsFromRootManifest.js'
import { loadNpmrcConfig } from './loadNpmrcFiles.js'
import { inheritDlxConfig, pickIniConfig } from './localConfig.js'
@@ -321,11 +321,8 @@ export async function getConfig (opts: {
...networkConfigs.registries,
}
pnpmConfig.registries = { ...registriesFromNpmrc }
const defaultCreds = getDefaultCreds(pnpmConfig.authConfig)
pnpmConfig.configByUri = {
...networkConfigs.configByUri,
...defaultCreds ? { '': { creds: defaultCreds } } : {},
}
pnpmConfig.configByUri = { ...networkConfigs.configByUri }
// tokenHelper must only come from user-level config (~/.npmrc or global auth.ini),
// not project-level, to prevent project .npmrc from executing arbitrary commands.
const userConfig = npmrcResult.userConfig as Record<string, string>

View File

@@ -3,9 +3,12 @@ import os from 'node:os'
import path from 'node:path'
import { envReplaceLossy } from '@pnpm/config.env-replace'
import { nerfDart } from '@pnpm/config.nerf-dart'
import normalizeRegistryUrl from 'normalize-registry-url'
import { readIniFileSync } from 'read-ini-file'
import { isNpmrcReadableKey } from './localConfig.js'
import { npmDefaults } from './npmDefaults.js'
export interface NpmrcConfigResult {
/**
@@ -69,6 +72,11 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
env
)
// Apply the same per-source rescope to CLI options so an unscoped
// `--_authToken` follows the same trust rule as one written into an .npmrc.
// We clone first to avoid mutating the caller's cliOptions object.
const cliOptions = rescopeUnscopedCreds({ ...opts.cliOptions }, '<command line>', warnings)
// Read pnpm builtin rc + inline defaults
const pnpmBuiltinConfig: Record<string, unknown> = {
...readAndFilterNpmrc(
@@ -83,7 +91,7 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
// Handle cafile: expand to ca certs.
// Priority: CLI > workspace > auth.ini > user > defaults
loadCAFile([
opts.cliOptions,
cliOptions,
workspaceNpmrc,
pnpmAuthConfig,
userConfig,
@@ -93,7 +101,7 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
// Merge all sources (lowest to highest priority):
// builtin < defaults < user < auth.ini < workspace < CLI
const mergedConfig: Record<string, unknown> = {}
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, workspaceNpmrc, opts.cliOptions]) {
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, workspaceNpmrc, cliOptions]) {
for (const [key, value] of Object.entries(source)) {
if (isNpmrcReadableKey(key)) {
mergedConfig[key] = value
@@ -108,7 +116,7 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
...userConfig,
...pnpmAuthConfig,
...workspaceNpmrc,
...opts.cliOptions,
...cliOptions,
}
return {
@@ -121,6 +129,35 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
}
}
// Per-registry rc keys that, when written without a `//host/` prefix, fall
// through to whatever default registry the merged config settles on. We
// rewrite each such key to its URL-scoped form at load time, pinning it to
// the `registry=` value declared in the same source. A later layer can
// still override the merged registry, but it cannot pull along a credential
// or client certificate authored for a different host.
//
// Two groups:
// * auth keys — `_authToken` etc. Pinned to prevent credential leaks. npm
// rejects these unscoped since npm@9 (ERR_INVALID_AUTH); pnpm keeps them
// working but warns so users migrate before a future major drops support.
// * client certificate keys — `cert`/`key` (inline PEM). Pinned to prevent
// a client certificate (and the identity it carries) being presented to
// the wrong host. The `certfile`/`keyfile` path variants are not in
// `NPM_AUTH_SETTINGS`, so unscoped forms never reach the merged config
// in the first place — only the URL-scoped `//host/:certfile=...` and
// `//host/:keyfile=...` forms are honored, and those are already pinned
// to their authoring registry by construction.
//
// `ca`/`cafile` are intentionally left unscoped-by-default: they're trust
// anchors, not credentials, and corporate MITM-proxy setups rely on them
// applying globally to every HTTPS request. The default registry override
// can't weaponize an unscoped CA (the attacker would need a cert signed
// by it), so the same pinning isn't warranted.
const UNSCOPED_RESCOPABLE_KEYS = [
'_authToken', '_auth', 'username', '_password', 'tokenHelper',
'cert', 'key',
] as const
function readAndFilterNpmrc (
filePath: string,
warnings: string[],
@@ -157,7 +194,65 @@ function readAndFilterNpmrc (
result[key] = value
}
}
return result
return rescopeUnscopedCreds(result, filePath, warnings)
}
// Rewrite any unscoped per-registry keys in `source` to their URL-scoped
// equivalents (`//host[:port]/path/:<key>=...`) using `source.registry` —
// or the builtin default registry if the source doesn't declare its own.
// This pins each layer's credential, client certificate, or CA setting to
// the registry that layer named (or the implicit npmjs default), so a
// later layer overriding `registry=` cannot pull a setting authored for
// one host along to a different host. A URL-scoped key for the same
// registry already present in `source` wins; we never overwrite an
// explicit scoped value.
//
// Each rewrite triggers a deprecation warning so users migrate to writing
// the URL-scoped form directly. npm has rejected unscoped credentials
// outright since `npm@9` (`ERR_INVALID_AUTH`).
function rescopeUnscopedCreds (
source: Record<string, unknown>,
sourceLabel: string,
warnings: string[]
): Record<string, unknown> {
// Bail early if there's nothing to rescope. This skips the nerfDart call
// when a source like the builtin pnpmrc has only a `registry=` line —
// rescoping there would do nothing anyway.
if (!UNSCOPED_RESCOPABLE_KEYS.some(key => key in source)) {
return source
}
const rawRegistry = typeof source.registry === 'string' && source.registry !== '' ? source.registry : null
const fallbackRegistry = rawRegistry ?? npmDefaults.registry
let nerfedRegistry: string
try {
nerfedRegistry = nerfDart(normalizeRegistryUrl(fallbackRegistry))
} catch {
// `registry=` resolved to something `URL` can't parse — often an
// unresolved `${VAR}` placeholder that left the string empty. Drop the
// unscoped keys (a bare token is unsafe to bind anywhere) and warn.
const dropped = UNSCOPED_RESCOPABLE_KEYS.filter(key => key in source)
for (const key of dropped) delete source[key]
warnings.push(`Unscoped per-registry settings (${dropped.join(', ')}) in "${sourceLabel}" were ignored: ` +
`the source's "registry" value (${JSON.stringify(source.registry)}) is not a parseable URL, so pnpm cannot pin them anywhere safe. ` +
'Write them URL-scoped (e.g. "//registry.example.com/:_authToken=...") to send them to a specific registry.')
return source
}
const rescoped: string[] = []
for (const key of UNSCOPED_RESCOPABLE_KEYS) {
if (!(key in source)) continue
const scopedKey = `${nerfedRegistry}:${key}`
if (!(scopedKey in source)) {
source[scopedKey] = source[key]
}
delete source[key]
rescoped.push(key)
}
if (rescoped.length > 0) {
warnings.push(`Unscoped per-registry settings (${rescoped.join(', ')}) in "${sourceLabel}" are deprecated. ` +
`pnpm pinned them to "${nerfedRegistry}" for this run, but a future release will stop supporting unscoped per-registry settings. ` +
`Write them as "${nerfedRegistry}:${rescoped[0]}=..." instead.`)
}
return source
}
// Use the lossy variant so unresolved `${VAR}` placeholders become '' (each

View File

@@ -110,7 +110,6 @@ const NPM_AUTH_SETTINGS = [
'_authToken',
'_password',
'email',
'keyfile',
'username',
]

View File

@@ -338,15 +338,19 @@ test('.npmrc does not load pnpm settings', async () => {
},
})
// rc options appear as usual
// rc options appear as usual. Unscoped credentials (`username`,
// `_authToken`) are rescoped to the file's registry at load — the .npmrc
// here doesn't set its own `registry=`, so they pin to the npmjs default.
expect(config.authConfig).toMatchObject({
'//my-org.registry.example.com:username': 'some-employee',
'//my-org.registry.example.com:_authToken': 'some-employee-token',
'@my-org:registry': 'https://my-org.registry.example.com',
'@jsr:registry': 'https://not-actually-jsr.example.com',
username: 'example-user-name',
_authToken: 'example-auth-token',
'//registry.npmjs.org/:username': 'example-user-name',
'//registry.npmjs.org/:_authToken': 'example-auth-token',
})
expect(config.authConfig.username).toBeUndefined()
expect(config.authConfig._authToken).toBeUndefined()
// workspace-specific settings are omitted
expect(config.authConfig['dlx-cache-max-age']).toBeUndefined()
@@ -854,6 +858,307 @@ describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
})
})
describe('unscoped credentials are pinned to the registry declared in their source file', () => {
// Each .npmrc / auth.ini gets its unscoped credential keys rewritten to
// URL-scoped form using the same source's `registry=` value (or the npmjs
// default if it has none). A later layer overriding `registry=` therefore
// cannot rebind the credential to its own registry — the credential is
// already pinned to the URL its author intended.
let originalXdg: string | undefined
let configHome: string
let userconfig: string
beforeEach(() => {
prepareEmpty()
fs.mkdirSync('user-home')
userconfig = path.resolve('user-home', '.npmrc')
configHome = path.resolve('xdg-config')
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
originalXdg = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = configHome
})
afterEach(() => {
if (originalXdg != null) {
process.env.XDG_CONFIG_HOME = originalXdg
} else {
delete process.env.XDG_CONFIG_HOME
}
})
test('pins user-level _authToken to that file\'s registry, never the workspace registry', async () => {
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n_authToken=user-secret\n', 'utf8')
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
test('pins user-level _auth (basic) the same way', async () => {
// cspell:disable-next-line
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n_auth=dXNlcjpwYXNz\n', 'utf8')
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { basicAuth: { username: 'user', password: 'pass' } } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
test('pins user-level username/_password the same way', async () => {
// cspell:disable-next-line
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\nusername=alice\n_password=cGFzcw==\n', 'utf8')
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { basicAuth: { username: 'alice', password: 'pass' } } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
test('auth.ini with no registry of its own falls back to the npmjs default', async () => {
// The split-file case: ~/.npmrc declares a registry but no creds; auth.ini
// declares an unscoped credential with no registry. Each file rescopes in
// isolation, so the credential pins to the builtin npmjs default — NOT to
// whatever the workspace later overrides the merged registry to.
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n', 'utf8')
fs.writeFileSync(
path.join(configHome, 'pnpm', 'auth.ini'),
'_authToken=user-secret\n',
'utf8'
)
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//registry.npmjs.org/': { creds: { authToken: 'user-secret' } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
expect(config.configByUri['//trusted.example.com/']).toBeUndefined()
})
test('user-level credentials work when no workspace .npmrc exists', async () => {
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n_authToken=user-secret\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
})
})
test('workspace-supplied unscoped credentials pin to the workspace registry', async () => {
fs.writeFileSync(userconfig, '', 'utf8')
fs.writeFileSync('.npmrc', 'registry=https://workspace.example.com/\n_authToken=workspace-token\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//workspace.example.com/': { creds: { authToken: 'workspace-token' } },
})
})
test('explicit URL-scoped credentials pass through unchanged', async () => {
fs.writeFileSync(
userconfig,
'registry=https://trusted.example.com/\n//trusted.example.com/:_authToken=user-secret\n',
'utf8'
)
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
const { config, warnings } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
})
// URL-scoped tokens should NOT trigger the deprecation warning.
expect(warnings.join('\n')).not.toMatch(/deprecated/i)
})
test('CLI --registry override does not pull an unscoped user-level token along', async () => {
// Same trust boundary as the workspace case: an unscoped token is ambient
// and shouldn't follow whatever registry the CLI happens to point at.
fs.writeFileSync(userconfig, '_authToken=user-secret\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig, registry: 'https://attacker.example.com/' },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
// The token rescoped to the npmjs default when the user file was read.
expect(config.configByUri).toMatchObject({
'//registry.npmjs.org/': { creds: { authToken: 'user-secret' } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
test('pins inline client cert/key to the file\'s registry, never the workspace registry', async () => {
const inlineCert = '-----BEGIN CERTIFICATE-----\\ncertbody\\n-----END CERTIFICATE-----'
const inlineKey = '-----BEGIN PRIVATE KEY-----\\nkeybody\\n-----END PRIVATE KEY-----'
fs.writeFileSync(
userconfig,
`registry=https://trusted.example.com/\ncert=${inlineCert}\nkey=${inlineKey}\n`,
'utf8'
)
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
const { config } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
// `\n` escapes are expanded to real newlines by getNetworkConfigs.
expect(config.configByUri['//trusted.example.com/']?.tls).toMatchObject({
cert: inlineCert.replace(/\\n/g, '\n'),
key: inlineKey.replace(/\\n/g, '\n'),
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
})
describe('unscoped credential deprecation warning', () => {
// pnpm warns whenever it reads any unscoped auth value from an .npmrc /
// auth.ini, regardless of whether the rebind defense fires. URL-scoped tokens
// have been npm's recommended pattern since npm@9, and unscoped credentials
// are slated for removal in a future major release.
let originalXdg: string | undefined
let configHome: string
let userconfig: string
beforeEach(() => {
prepareEmpty()
fs.mkdirSync('user-home')
userconfig = path.resolve('user-home', '.npmrc')
configHome = path.resolve('xdg-config')
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
originalXdg = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = configHome
})
afterEach(() => {
if (originalXdg != null) {
process.env.XDG_CONFIG_HOME = originalXdg
} else {
delete process.env.XDG_CONFIG_HOME
}
})
test('warns about unscoped _authToken in user .npmrc', async () => {
fs.writeFileSync(userconfig, 'registry=https://example.com/\n_authToken=secret\n', 'utf8')
const { warnings } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(warnings.find(w => w.includes('Unscoped per-registry settings'))).toBeDefined()
expect(warnings.find(w => w.includes('_authToken'))).toBeDefined()
expect(warnings.find(w => w.includes(userconfig))).toBeDefined()
})
test('warns about unscoped _auth, username, _password', async () => {
// _auth and _password are base64-encoded per npm convention.
// cspell:disable-next-line
fs.writeFileSync(userconfig, '_auth=dXNlcjpwYXNz\nusername=alice\n_password=cGFzcw==\n', 'utf8')
const { warnings } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
const warning = warnings.find(w => w.includes('Unscoped per-registry settings'))
expect(warning).toBeDefined()
expect(warning).toContain('_auth')
expect(warning).toContain('username')
expect(warning).toContain('_password')
})
test('warns about unscoped credentials in workspace .npmrc too', async () => {
fs.writeFileSync('.npmrc', 'registry=https://workspace.example.com/\n_authToken=workspace-token\n', 'utf8')
const { warnings } = await getConfig({
cliOptions: {},
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
const warning = warnings.find(w => w.includes('Unscoped per-registry settings'))
expect(warning).toBeDefined()
expect(warning).toContain(path.resolve('.npmrc'))
})
test('does not warn when only URL-scoped credentials are present', async () => {
fs.writeFileSync(
userconfig,
'registry=https://example.com/\n//example.com/:_authToken=secret\n',
'utf8'
)
const { warnings } = await getConfig({
cliOptions: { userconfig },
env: { ...env, XDG_CONFIG_HOME: configHome },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(warnings.find(w => w.includes('Unscoped per-registry settings'))).toBeUndefined()
})
})
test('throw error if --save-prod is used with --save-peer', async () => {
await expect(getConfig({
cliOptions: {

View File

@@ -142,6 +142,7 @@
"jega",
"jhcg",
"jnbpamcxayl",
"junyi",
"kebabcase",
"kevva",
"keyfile",
@@ -285,6 +286,11 @@
"renderable",
"replit",
"reqheaders",
"rescopable",
"rescope",
"rescoped",
"rescopes",
"rescoping",
"rimrafed",
"rmgr",
"rpmdevtools",

View File

@@ -213,7 +213,7 @@ export async function handler (opts: AuditOptions, params: string[] = []): Promi
const { envLockfile, include, lockfile } = await loadAuditContext(opts)
const networkOptions = createAuditNetworkOptions(opts)
let auditReport!: AuditReport
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri)
try {
auditReport = await audit(lockfile, getAuthHeader, {
dispatcherOptions: {

View File

@@ -20,7 +20,7 @@ export async function auditSignatures (opts: AuditOptions): Promise<{ exitCode:
throw new PnpmError('AUDIT_NO_PACKAGES', 'No installed packages found to audit')
}
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri)
const networkOptions = createAuditNetworkOptions(opts)
const result = await verifySignatures(packages, getAuthHeader, {
ca: networkOptions.ca,

View File

@@ -52,7 +52,7 @@ export async function fetchPackageInfo (
}
const registry = pickRegistryForPackage(opts.registries, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const fetchResult = await fetchMetadataFromFromRegistry(
{
fetch: fetchFromRegistry,

View File

@@ -68,7 +68,7 @@ export interface Client {
export function createClient (opts: ClientOptions): Client {
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri)
// One per-install LRU shared with both the resolver's pickPackage
// pass and the verifier's lookup chain. When the resolver populates
@@ -87,7 +87,7 @@ export function createClient (opts: ClientOptions): Client {
export function createResolver (opts: Omit<ClientOptions, 'storeIndex'>): { resolve: ResolveFunction, resolveLatest: ResolveLatestDispatcher, clearCache: () => void } {
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri)
return _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
}

View File

@@ -71,27 +71,6 @@ test('installing a package that need authentication, using password', async () =
project.has('@pnpm.e2e/needs-auth')
})
test('a package that need authentication, legacy way', async () => {
const project = prepareEmpty()
await addUser({
email: 'foo@bar.com',
password: 'bar',
username: 'foo',
})
const configByUri: Record<string, RegistryConfig> = {
'': { creds: { basicAuth: { username: 'foo', password: 'bar' } } },
}
await addDependenciesToPackage({}, ['@pnpm.e2e/needs-auth'], testDefaults({}, {
configByUri,
}, {
configByUri,
}))
project.has('@pnpm.e2e/needs-auth')
})
test('a scoped package that need authentication specific to scope', async () => {
const project = prepareEmpty()

View File

@@ -100,7 +100,7 @@ export async function resolveAndInstallConfigDeps (
// Resolve missing deps
const fetch = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const { resolveFromNpm } = createNpmResolver(fetch, getAuthHeader, opts)
await Promise.all(depsToResolve.map(async ({ name, specifier }) => {

View File

@@ -30,7 +30,7 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf
}
const fetch = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const { resolveFromNpm } = createNpmResolver(fetch, getAuthHeader, opts)
// Extract existing specifiers from configDependencies (handles both old and new formats)

View File

@@ -4,24 +4,15 @@ import { PnpmError } from '@pnpm/error'
import type { Creds, RegistryConfig, TokenHelper } from '@pnpm/types'
export function getAuthHeadersFromCreds (
configByUri: Record<string, RegistryConfig>,
defaultRegistry: string
configByUri: Record<string, RegistryConfig>
): Record<string, string> {
const authHeaderValueByURI: Record<string, string> = {}
for (const [uri, registryConfig] of Object.entries(configByUri)) {
if (uri === '') continue // default auth handled below
const header = credsToHeader(registryConfig.creds)
if (header) {
authHeaderValueByURI[uri] = header
}
}
const defaultConfig = configByUri['']
if (defaultConfig?.creds) {
const header = credsToHeader(defaultConfig.creds)
if (header) {
authHeaderValueByURI[defaultRegistry] = header
}
}
return authHeaderValueByURI
}

View File

@@ -5,11 +5,9 @@ import { getAuthHeadersFromCreds } from './getAuthHeadersFromConfig.js'
import { removePort } from './helpers/removePort.js'
export function createGetAuthHeaderByURI (
configByUri: Record<string, RegistryConfig>,
defaultRegistry?: string
configByUri: Record<string, RegistryConfig>
): (uri: string) => string | undefined {
const registry = defaultRegistry ? nerfDart(defaultRegistry) : '//registry.npmjs.org/'
const authHeaders = getAuthHeadersFromCreds(configByUri, registry)
const authHeaders = getAuthHeadersFromCreds(configByUri)
if (Object.keys(authHeaders).length === 0) return (uri: string) => basicAuth(new URL(uri))
return getAuthHeaderByURI.bind(null, authHeaders, getMaxParts(Object.keys(authHeaders)))
}

View File

@@ -69,15 +69,6 @@ test('getAuthHeaderByURI() when the registry has pathnames', () => {
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm/foo/-/foo-1.0.0.tgz')).toBe('Bearer abc123')
})
test('getAuthHeaderByURI() with default registry auth', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI(
{ '': { creds: { authToken: 'default-token' } } },
'https://registry.npmjs.org/'
)
expect(getAuthHeaderByURI('https://registry.npmjs.org/')).toBe('Bearer default-token')
expect(getAuthHeaderByURI('https://registry.npmjs.org/foo/-/foo-1.0.0.tgz')).toBe('Bearer default-token')
})
test('getAuthHeaderByURI() with basic auth via basicAuth', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//reg.com/': { creds: { basicAuth: { username: 'user', password: 'pass' } } },

View File

@@ -33,7 +33,7 @@ describe('getAuthHeadersFromCreds()', () => {
const result = getAuthHeadersFromCreds({
'//registry.npmjs.org/': { creds: { authToken: 'abc123' } },
'//registry.hu/': { creds: { authToken: 'def456' } },
}, '//registry.npmjs.org/')
})
expect(result).toStrictEqual({
'//registry.npmjs.org/': 'Bearer abc123',
'//registry.hu/': 'Bearer def456',
@@ -42,23 +42,15 @@ describe('getAuthHeadersFromCreds()', () => {
it('should convert basicAuth to Basic header', () => {
const result = getAuthHeadersFromCreds({
'//registry.foobar.eu/': { creds: { basicAuth: { username: 'foobar', password: 'foobar' } } },
}, '//registry.npmjs.org/')
})
expect(result).toStrictEqual({
'//registry.foobar.eu/': 'Basic Zm9vYmFyOmZvb2Jhcg==',
})
})
it('should handle default registry auth (empty key)', () => {
const result = getAuthHeadersFromCreds({
'': { creds: { authToken: 'default-token' } },
}, '//reg.com/')
expect(result).toStrictEqual({
'//reg.com/': 'Bearer default-token',
})
})
it('should execute tokenHelper', () => {
const result = getAuthHeadersFromCreds({
'//registry.foobar.eu/': { creds: { tokenHelper: [osTokenHelper[osFamily]] } },
}, '//registry.npmjs.org/')
})
expect(result).toStrictEqual({
'//registry.foobar.eu/': 'Bearer token-from-spawn',
})
@@ -66,7 +58,7 @@ describe('getAuthHeadersFromCreds()', () => {
it('should prepend Bearer to raw token from tokenHelper', () => {
const result = getAuthHeadersFromCreds({
'//registry.foobar.eu/': { creds: { tokenHelper: [osRawTokenHelper[osFamily]] } },
}, '//registry.npmjs.org/')
})
expect(result).toStrictEqual({
'//registry.foobar.eu/': 'Bearer raw-token-no-scheme',
})
@@ -74,15 +66,15 @@ describe('getAuthHeadersFromCreds()', () => {
it('should throw an error if the token helper fails', () => {
expect(() => getAuthHeadersFromCreds({
'//reg.com/': { creds: { tokenHelper: [osErrorTokenHelper[osFamily]] } },
}, '//registry.npmjs.org/')).toThrow('Exit code')
})).toThrow('Exit code')
})
it('should throw an error if the token helper returns an empty token', () => {
expect(() => getAuthHeadersFromCreds({
'//reg.com/': { creds: { tokenHelper: [osEmptyTokenHelper[osFamily]] } },
}, '//registry.npmjs.org/')).toThrow('returned an empty token')
})).toThrow('returned an empty token')
})
it('should return empty object when no auth infos', () => {
const result = getAuthHeadersFromCreds({}, '//registry.npmjs.org/')
const result = getAuthHeadersFromCreds({})
expect(result).toStrictEqual({})
})
})

3
pnpm-lock.yaml generated
View File

@@ -2676,6 +2676,9 @@ importers:
'@pnpm/config.matcher':
specifier: workspace:*
version: link:../matcher
'@pnpm/config.nerf-dart':
specifier: 'catalog:'
version: 1.0.1
'@pnpm/constants':
specifier: workspace:*
version: link:../../core/constants

View File

@@ -38,7 +38,7 @@ export async function updateDeprecation (
): Promise<string> {
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, registryUrl)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const authHeader = getAuthHeader(registryUrl)

View File

@@ -201,7 +201,7 @@ function getAuthHeaderForRegistry (
configByUri: Record<string, RegistryConfig> | undefined,
registryUrl: string
): string | undefined {
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {}, registryUrl)
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {})
return getAuthHeader(registryUrl)
}

View File

@@ -182,7 +182,7 @@ function getAuthHeaderForRegistry (
configByUri: Record<string, RegistryConfig> | undefined,
registryUrl: string
): string | undefined {
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {}, registryUrl)
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {})
return getAuthHeader(registryUrl)
}

View File

@@ -52,7 +52,7 @@ export async function handler (opts: PingOptions): Promise<string> {
pingUrlObject.searchParams.set('write', 'true')
const pingUrl = pingUrlObject.toString()
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, normalizedRegistryUrl)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const authHeaderValue = getAuthHeader(normalizedRegistryUrl)
const fetchFromRegistry = createFetchFromRegistry(opts)

View File

@@ -87,7 +87,7 @@ export async function handler (
searchUrl.searchParams.set('size', (opts.searchLimit ?? 20).toString())
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries?.default)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const response = await fetchFromRegistry(searchUrl.toString(), {
authHeaderValue: getAuthHeader(registry),

View File

@@ -133,6 +133,6 @@ export function getAuthHeaderForRegistry (
configByUri: Record<string, RegistryConfig> | undefined,
registryUrl: string
): string | undefined {
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {}, registryUrl)
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {})
return getAuthHeader(registryUrl)
}

View File

@@ -104,7 +104,7 @@ async function unpublishPackage (
): Promise<string> {
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, registryUrl)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const authHeader = getAuthHeader(registryUrl)

View File

@@ -34,7 +34,7 @@ export function help (): string {
export async function handler (opts: WhoamiOptions): Promise<string> {
const registryUrl = normalizeRegistryUrl(opts.registries?.default ?? 'https://registry.npmjs.org/')
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {}, registryUrl)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const authHeader = getAuthHeader(registryUrl)
if (!authHeader) {
throw new PnpmError('WHOAMI_UNAUTHORIZED', 'You must be logged in to use whoami')

View File

@@ -251,15 +251,6 @@ function findRegistryInfo (
tls = { ...entry.tls, ...tls }
}
const isDefaultRegistry =
nonNormalizedRegistry === registries.default ||
registry === registries.default ||
registry === parseSupportedRegistryUrl(registries.default)?.normalizedUrl
if (isDefaultRegistry) {
creds ??= configByUri['']?.creds
}
return {
registry,
config: { creds, tls },

View File

@@ -22,8 +22,7 @@ export interface StageContext {
export function createStageContext (opts: StageOptions, packageName?: string): StageContext {
const registry = getStageRegistry(opts, packageName)
const getAuthHeaderByUri = createGetAuthHeaderByURI(
opts.configByUri ?? {} as Record<string, RegistryConfig>,
registry
opts.configByUri ?? {} as Record<string, RegistryConfig>
)
return {
opts,

View File

@@ -215,7 +215,7 @@ export function createResolutionVerifiers (
timeout: opts.timeout ?? 60_000,
fetchWarnTimeoutMs: opts.fetchWarnTimeoutMs ?? 10_000,
}
const getAuthHeaderValueByURI = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries.default)
const getAuthHeaderValueByURI = createGetAuthHeaderByURI(opts.configByUri ?? {})
const verifiers: ResolutionVerifier[] = []
const npmVerifier = createNpmResolutionVerifier({
minimumReleaseAge: opts.minimumReleaseAge,