mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-31 12:10:49 -04:00
* 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.
78 lines
4.5 KiB
TypeScript
78 lines
4.5 KiB
TypeScript
import { expect, test } from '@jest/globals'
|
|
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
|
|
|
|
const configByUri = {
|
|
'//reg.com/': { creds: { authToken: 'abc123' } },
|
|
'//reg.co/tarballs/': { creds: { authToken: 'xxx' } },
|
|
'//reg.gg:8888/': { creds: { authToken: '0000' } },
|
|
'//custom.domain.com/artifactory/api/npm/npm-virtual/': { creds: { authToken: 'xyz' } },
|
|
}
|
|
|
|
test('getAuthHeaderByURI()', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI(configByUri)
|
|
expect(getAuthHeaderByURI('https://reg.com/')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://reg.com/foo/-/foo-1.0.0.tgz')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://reg.com:8080/foo/-/foo-1.0.0.tgz')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://reg.io/foo/-/foo-1.0.0.tgz')).toBeUndefined()
|
|
expect(getAuthHeaderByURI('https://reg.co/tarballs/foo/-/foo-1.0.0.tgz')).toBe('Bearer xxx')
|
|
expect(getAuthHeaderByURI('https://reg.gg:8888/foo/-/foo-1.0.0.tgz')).toBe('Bearer 0000')
|
|
expect(getAuthHeaderByURI('https://reg.gg:8888/foo/-/foo-1.0.0.tgz')).toBe('Bearer 0000')
|
|
})
|
|
|
|
test('getAuthHeaderByURI() basic auth without settings', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI({})
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.io/')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:@reg.io/')).toBe('Basic ' + btoa('user:'))
|
|
expect(getAuthHeaderByURI('https://:secret@reg.io/')).toBe('Basic ' + btoa(':secret'))
|
|
expect(getAuthHeaderByURI('https://user@reg.io/')).toBe('Basic ' + btoa('user:'))
|
|
})
|
|
|
|
test('getAuthHeaderByURI() basic auth with settings', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI(configByUri)
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.com/')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.com/foo/-/foo-1.0.0.tgz')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.com:8080/foo/-/foo-1.0.0.tgz')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.io/foo/-/foo-1.0.0.tgz')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.co/tarballs/foo/-/foo-1.0.0.tgz')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.gg:8888/foo/-/foo-1.0.0.tgz')).toBe('Basic ' + btoa('user:secret'))
|
|
expect(getAuthHeaderByURI('https://user:secret@reg.gg:8888/foo/-/foo-1.0.0.tgz')).toBe('Basic ' + btoa('user:secret'))
|
|
})
|
|
|
|
test('getAuthHeaderByURI() https port 443 checks', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI(configByUri)
|
|
expect(getAuthHeaderByURI('https://custom.domain.com:443/artifactory/api/npm/npm-virtual/')).toBe('Bearer xyz')
|
|
expect(getAuthHeaderByURI('https://custom.domain.com:443/artifactory/api/npm/')).toBeUndefined()
|
|
expect(getAuthHeaderByURI('https://custom.domain.com:443/artifactory/api/npm/-/@platform/device-utils-1.0.0.tgz')).toBeUndefined()
|
|
expect(getAuthHeaderByURI('https://custom.domain.com:443/artifactory/api/npm/npm-virtual/@platform/device-utils/-/@platform/device-utils-1.0.0.tgz')).toBe('Bearer xyz')
|
|
})
|
|
|
|
test('getAuthHeaderByURI() when default ports are specified', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI({
|
|
'//reg.com/': { creds: { authToken: 'abc123' } },
|
|
})
|
|
expect(getAuthHeaderByURI('https://reg.com:443/')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('http://reg.com:80/')).toBe('Bearer abc123')
|
|
})
|
|
|
|
test('returns undefined when the auth header is not found', () => {
|
|
expect(createGetAuthHeaderByURI({})('http://reg.com')).toBeUndefined()
|
|
})
|
|
|
|
test('getAuthHeaderByURI() when the registry has pathnames', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI({
|
|
'//npm.pkg.github.com/pnpm/': { creds: { authToken: 'abc123' } },
|
|
})
|
|
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm/')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm/foo')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm/foo/')).toBe('Bearer abc123')
|
|
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm/foo/-/foo-1.0.0.tgz')).toBe('Bearer abc123')
|
|
})
|
|
|
|
test('getAuthHeaderByURI() with basic auth via basicAuth', () => {
|
|
const getAuthHeaderByURI = createGetAuthHeaderByURI({
|
|
'//reg.com/': { creds: { basicAuth: { username: 'user', password: 'pass' } } },
|
|
})
|
|
expect(getAuthHeaderByURI('https://reg.com/')).toBe('Basic ' + btoa('user:pass'))
|
|
})
|