Skip to content

fix: Enforce allow-git/allow-file/allow-directory/allow-remote configs at the arborist resolution layer#9348

Open
owlstronaut wants to merge 2 commits into
latestfrom
owl/add-checkallow
Open

fix: Enforce allow-git/allow-file/allow-directory/allow-remote configs at the arborist resolution layer#9348
owlstronaut wants to merge 2 commits into
latestfrom
owl/add-checkallow

Conversation

@owlstronaut
Copy link
Copy Markdown
Contributor

@owlstronaut owlstronaut commented May 12, 2026

Pacote already enforces these settings inside FetcherBase.get, but it only sees what its callers tell it — specifically the _isRoot flag, and it never gets called at all for directory specs, which arborist resolves by creating a Link node directly.

That left two gaps: pacote couldn't gate directory deps because it never saw them, and the _isRoot hint it received was wrong for any transitive that flowed through a synthesized virtualRoot or a hoisted location. Moving the check into arborist closes both: arborist is the only layer that has the real tree context (the actual edge being resolved, with its true from node) and the only layer that sees every spec type before any branching. Pacote's gate stays in place as defense-in-depth for direct pacote consumers, but the authoritative enforcement now lives where the truth lives.

fixes #9347

@owlstronaut owlstronaut requested review from a team as code owners May 12, 2026 16:06
@owlstronaut owlstronaut force-pushed the owl/add-checkallow branch 3 times, most recently from 9e9d7dd to 3f6545d Compare May 12, 2026 17:28
@owlstronaut owlstronaut force-pushed the owl/add-checkallow branch from 3f6545d to 8605d10 Compare May 12, 2026 17:44
@owlstronaut owlstronaut force-pushed the owl/add-checkallow branch from a42d830 to d4b454e Compare May 12, 2026 18:24
Copy link
Copy Markdown

@nishantms nishantms left a comment

Choose a reason for hiding this comment

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

Read-through of the arborist allow-* enforcement. Approach (lift the gate above the symlink branch and the manifest cache) is right, and the soft-fail-via-#loadFailures handling for optional transitives is the correct behavior. A few non-blocking nits below.

Comment on lines +656 to +661
const optionFor = {
git: 'allowGit',
remote: 'allowRemote',
file: 'allowFile',
directory: 'allowDirectory',
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] optionFor is a constant lookup table reallocated on every #checkAllow call, which runs once per spec resolution during a tree build. Cheap, but trivially hoistable to module scope:

const ALLOW_OPTION_FOR_TYPE = {
  git: 'allowGit',
  remote: 'allowRemote',
  file: 'allowFile',
  directory: 'allowDirectory',
}

Then the method body becomes const optName = ALLOW_OPTION_FOR_TYPE[spec.type].

Comment on lines +1275 to +1288
try {
this.#checkAllow(spec, edge)
} catch (error) {
error.requiredBy = edge?.from?.location || '.'
const n = new Node({
name,
parent,
error,
installLinks: this.installLinks,
legacyPeerDeps: this.legacyPeerDeps,
})
this.#loadFailures.add(n)
return n
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This block duplicates the failure-handling shape that already exists ~50 lines below in the #fetchManifest catch path: decorate error with requiredBy → wrap in Node({ name, parent, error, installLinks, legacyPeerDeps })this.#loadFailures.add(n) → return.

Worth extracting a tiny helper now while the duplication is fresh, so the soft-fail semantics live in one place:

#failureNode (name, parent, error, edge) {
  error.requiredBy = edge?.from?.location || '.'
  const n = new Node({
    name,
    parent,
    error,
    installLinks: this.installLinks,
    legacyPeerDeps: this.legacyPeerDeps,
  })
  this.#loadFailures.add(n)
  return n
}

Both call sites collapse to one line.

Comment on lines +847 to +850
const resolvedHost = new URL(node.resolved).hostname
// pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname
return resolvedHost === registryHost
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict === on hostnames is a bit tighter than the registry actually requires, and will silently mis-fire in two real configs:

  1. Case — URL hostnames are case-insensitive. Node's URL parser already lowercases, so this is fine in practice today, but a .toLowerCase() on both sides is cheap insurance against future parser changes / non-ASCII hosts.
  2. Mirror / CNAME setups — a user with registry=https://npm-mirror.example.com/ whose lockfile resolved against https://registry.npmjs.org/... (totally normal after switching registries on a checked-in lockfile) falls through to allowRemote enforcement and breaks installs that work fine on master.

A more honest check, since this.options already enumerates every host the user has authed against:

const resolvedHost = new URL(node.resolved).hostname.toLowerCase()
const pickedHost = new URL(pickRegistry(npa(node.name), this.options)).hostname.toLowerCase()
if (resolvedHost === pickedHost) {
  return true
}
// Allow any host listed under //<host>/:_authToken etc. — covers mirrors,
// scope overrides, and lockfiles carried across registries.
const knownHosts = new Set(
  Object.keys(this.options || {})
    .filter(k => k.startsWith('//'))
    .map(k => k.slice(2).split('/')[0].toLowerCase())
)
return knownHosts.has(resolvedHost)

If that feels like scope creep, at minimum: lowercase both sides, and add a test for the mirror-mismatch case so the strictness is a conscious choice.

const { npm, registry } = await loadMockNpm(t, {
config: {
'allow-remote': 'none',
audit: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This is the only one of the five new allow-* tests that sets audit: false. The other four don't, and there's no comment explaining why. A future reader will wonder if it's load-bearing here, and why it isn't needed in the others.

If it's because this is the only test that actually goes through mockRegistry and would otherwise trigger an audit roundtrip, say so:

config: {
  'allow-remote': 'none',
  // disable audit so the test doesn't need to mock the audit endpoint;
  // this is the only allow-* test that actually hits the registry
  audit: false,
},

Or set audit: false consistently across all five so they look the same. Either reads fine — the silent inconsistency is what's awkward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] allow-remote=none breaks pure-registry installs

2 participants