fix: Enforce allow-git/allow-file/allow-directory/allow-remote configs at the arborist resolution layer#9348
fix: Enforce allow-git/allow-file/allow-directory/allow-remote configs at the arborist resolution layer#9348owlstronaut wants to merge 2 commits into
Conversation
9e9d7dd to
3f6545d
Compare
3f6545d to
8605d10
Compare
a42d830 to
d4b454e
Compare
nishantms
left a comment
There was a problem hiding this comment.
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.
| const optionFor = { | ||
| git: 'allowGit', | ||
| remote: 'allowRemote', | ||
| file: 'allowFile', | ||
| directory: 'allowDirectory', | ||
| } |
There was a problem hiding this comment.
[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].
| 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 | ||
| } |
There was a problem hiding this comment.
[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.
| 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 |
There was a problem hiding this comment.
Strict === on hostnames is a bit tighter than the registry actually requires, and will silently mis-fire in two real configs:
- Case — URL hostnames are case-insensitive. Node's
URLparser 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. - Mirror / CNAME setups — a user with
registry=https://npm-mirror.example.com/whose lockfile resolved againsthttps://registry.npmjs.org/...(totally normal after switching registries on a checked-in lockfile) falls through toallowRemoteenforcement and breaks installs that work fine onmaster.
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, |
There was a problem hiding this comment.
[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.
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