Skip to content

Commit 1f17566

Browse files
committed
fix: allow-remote=none does not block registry tarballs
1 parent e0f12f7 commit 1f17566

2 files changed

Lines changed: 66 additions & 0 deletions

File tree

test/lib/commands/install.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,51 @@ t.test('exec commands', async t => {
344344
'optional transitive git dep is silently skipped'
345345
)
346346
})
347+
348+
t.test('allow-remote=none does not block registry tarballs', async t => {
349+
const { npm, registry } = await loadMockNpm(t, {
350+
config: {
351+
'allow-remote': 'none',
352+
audit: false,
353+
},
354+
prefixDir: {
355+
'package.json': JSON.stringify({
356+
...packageJson,
357+
dependencies: { abbrev: '^1.0.0' },
358+
}),
359+
abbrev,
360+
},
361+
})
362+
const manifest = registry.manifest({ name: 'abbrev' })
363+
await registry.package({ manifest })
364+
await registry.tarball({
365+
manifest: manifest.versions['1.0.0'],
366+
tarball: path.join(npm.prefix, 'abbrev'),
367+
})
368+
await npm.exec('install', [])
369+
const installed = require(path.join(npm.prefix, 'node_modules', 'abbrev', 'package.json'))
370+
t.equal(installed.name, 'abbrev', 'registry dep is installed despite allow-remote=none')
371+
})
372+
373+
t.test('allow-remote=none still blocks a user-supplied remote URL', async t => {
374+
const { npm } = await loadMockNpm(t, {
375+
config: {
376+
'allow-remote': 'none',
377+
},
378+
prefixDir: {
379+
'package.json': JSON.stringify({
380+
name: '@npmcli/test-package',
381+
version: '1.0.0',
382+
dependencies: { abbrev: 'https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz' },
383+
}),
384+
},
385+
})
386+
await t.rejects(
387+
npm.exec('install', []),
388+
{ code: 'EALLOWREMOTE' },
389+
'user-supplied remote URL is still blocked'
390+
)
391+
})
347392
})
348393

349394
t.test('completion', async t => {

workspaces/arborist/lib/arborist/reify.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const hgi = require('hosted-git-info')
44
const npa = require('npm-package-arg')
55
const packageContents = require('@npmcli/installed-package-contents')
66
const pacote = require('pacote')
7+
const { pickRegistry } = require('npm-registry-fetch')
78
const promiseAllRejectLate = require('promise-all-reject-late')
89
const runScript = require('@npmcli/run-script')
910
const { callLimit: promiseCallLimit } = require('promise-call-limit')
@@ -709,6 +710,9 @@ module.exports = cls => class Reifier extends cls {
709710
_isRoot: [...node.edgesIn].some(e =>
710711
e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
711712
),
713+
// pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
714+
// Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball.
715+
...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}),
712716
})
713717
// store nodes don't use Node class so node.package doesn't get updated
714718
if (node.isInStore) {
@@ -832,6 +836,23 @@ module.exports = cls => class Reifier extends cls {
832836
return wrapper
833837
}
834838

839+
// When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
840+
// pacote re-parses that with npa and gets spec.type === 'remote', so without an override the allow-remote gate would fire on every registry tarball (both =none and =root mis-fire).
841+
// Returns true only when we are confident this is a registry-mediated install: the node's inbound edges must all be registry-typed (no exotic spec smuggled the URL in) AND the resolved URL's host must match the registry npm-registry-fetch selected for this spec, so a tampered lockfile pointing at an attacker host still hits the gate.
842+
#isRegistryResolvedTarball (node) {
843+
if (!node.resolved || !node.isRegistryDependency) {
844+
return false
845+
}
846+
try {
847+
const resolvedHost = new URL(node.resolved).hostname
848+
// pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
849+
const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname
850+
return resolvedHost === registryHost
851+
} catch {
852+
return false
853+
}
854+
}
855+
835856
#registryResolved (resolved) {
836857
// the default registry url is a magic value meaning "the currently
837858
// configured registry".

0 commit comments

Comments
 (0)