diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index dbfc88ab63abc..efcc9631f5d52 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -290,9 +290,9 @@ range. Please note that this could leave your tree incomplete and some packages may not function as intended or designed. Changing this setting will not remove dependencies that are already installed. -As of npm 12 the default is \`none\`. Tarballs that share a hostname with the -configured registry (the typical case for the npm registry, GitHub Packages, -and most private registries) are still installed normally. If your registry +As of npm 12 the default is \`none\`. Tarballs under the configured registry +path are installed normally. npm also permits same-origin tarballs when it +can verify their exact URL against registry metadata. If your registry serves tarballs from a different host, set \`replace-registry-host\` or override this setting. Opt in explicitly per project (in \`.npmrc\`) or per command (on the CLI) when you intentionally install from a URL. diff --git a/test/lib/commands/ci.js b/test/lib/commands/ci.js index a90ca4b0cffe6..4899900889951 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -193,7 +193,7 @@ t.test('allow-remote=none blocks same-host tarball outside registry path', async lock.packages['node_modules/abbrev'].resolved = evilTarball lock.dependencies.abbrev.resolved = evilTarball - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { config: { audit: false, 'allow-remote': 'none', @@ -206,6 +206,8 @@ t.test('allow-remote=none blocks same-host tarball outside registry path', async 'package-lock.json': JSON.stringify(lock), }, }) + const manifest = registry.manifest({ name: 'abbrev' }) + await registry.package({ manifest }) await t.rejects( npm.exec('ci', []), diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 784c389c43756..4b3c593d5c48b 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -3,6 +3,7 @@ const { depth } = require('treeverse') const crypto = require('node:crypto') const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js') const nameFromFolder = require('@npmcli/name-from-folder') +const { carryRegistryPackageName } = require('../registry-package-name.js') // generate short hash key based on the dependency tree starting at this node const getKey = (startNode) => { @@ -59,6 +60,7 @@ module.exports = cls => class IsolatedReifier extends cls { resolved: node.resolved, root, }) + carryRegistryPackageName(node, newChild) // XXX top is from place-dep not lib/node.js newChild.top = { path: this.idealGraph.localPath } root.children.set(newChild.location, newChild) @@ -164,6 +166,8 @@ module.exports = cls => class IsolatedReifier extends cls { // Carry the source node's registry-dependency flag so the store node retains it. // IsolatedNode has no edges to recompute it from, and reify's registry-tarball allow-remote exemption depends on it. result.isRegistryDependency = node.isRegistryDependency + // Package metadata is not a trusted identity source, so preserve the name derived from source edges. + carryRegistryPackageName(node, result) // Same reasoning for allow-remote=root: the store node has no edgesIn, so capture from the source node whether it satisfies a valid edge from the project root or a workspace. result.isRootDependency = [...node.edgesIn].some(e => e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index b099d4d72c6a4..53ca659e27118 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -27,6 +27,7 @@ const relpath = require('../relpath.js') const { applyPatchToDir, patchIntegrity } = require('../patch.js') const { readFile } = require('node:fs/promises') const retirePath = require('../retire-path.js') +const { getRegistryPackageName } = require('../registry-package-name.js') const treeCheck = require('../tree-check.js') const Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap @@ -697,6 +698,11 @@ module.exports = cls => class Reifier extends cls { await this.#validateNodeModules(nm) if (!node.isLink) { + const isRoot = node.isRootDependency || [...node.edgesIn].some(e => + e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace) + ) + const allowRemote = this.options.allowRemote ?? 'all' + const remoteAllowed = allowRemote === 'all' || (allowRemote !== 'none' && isRoot) // in normal cases, node.resolved should *always* be set by now. // however, it is possible when a lockfile is damaged, or very old, // or in some other race condition bugs in npm v6, that a previously @@ -705,10 +711,15 @@ module.exports = cls => class Reifier extends cls { // Do the best with what we have, or else remove it from the tree // entirely, since we can't possibly reify it. let res = null + let registryTarballExemption = false if (node.resolved) { const registryResolved = this.#registryResolved(node.resolved) if (registryResolved) { - res = `${node.name}@${registryResolved}` + const registryPackageName = !remoteAllowed && getRegistryPackageName(node) + registryTarballExemption = !!registryPackageName && + await this.#isRegistryResolvedTarball(node, registryPackageName) + const packageName = registryTarballExemption ? registryPackageName : node.name + res = `${packageName}@${registryResolved}` } } else if (node.package.name && node.version) { res = `${node.package.name}@${node.version}` @@ -744,12 +755,10 @@ module.exports = cls => class Reifier extends cls { // A node counts as "root" for allow-* enforcement if it satisfies at least one valid dependency edge declared by the project root or a workspace. // node.parent is unsafe here: after hoisting, transitive packages can have the project root as their tree parent. // In the linked strategy the store node has no edgesIn, so isolated-reifier precomputes isRootDependency from the source node's edges. - _isRoot: node.isRootDependency || [...node.edgesIn].some(e => - e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace) - ), + _isRoot: isRoot, // pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs. // Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball. - ...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}), + ...(registryTarballExemption ? { allowRemote: 'all' } : {}), }) // store nodes don't use Node class so node.package doesn't get updated if (node.isInStore) { @@ -985,22 +994,54 @@ module.exports = cls => class Reifier extends cls { // When extracting a registry-resolved package, the spec we hand to pacote is name@URL. // 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). // Returns true only when we are confident this is a registry-mediated install. - #isRegistryResolvedTarball (node) { - if (!node.resolved || !node.isRegistryDependency) { + async #isRegistryResolvedTarball (node, packageName) { + // The caller only invokes this with a resolved URL and a trusted package + // name, but linked nodes retain an independent registry provenance flag. + if (!node.isRegistryDependency) { return false } + + let resolvedURL + let registry try { // Match the effective fetch URL, not the raw lockfile value. // #registryResolved applies replace-registry-host, rewriting a public-registry pin to the configured proxy/mirror so it matches. - const resolvedURL = new URL(this.#registryResolved(node.resolved)) - // pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency. - const registry = new URL(pickRegistry(npa(node.name), this.options)) - const registryPath = registry.pathname.replace(/\/?$/, '/') - return resolvedURL.origin === registry.origin && - (registryPath === '/' || resolvedURL.pathname.startsWith(registryPath)) + resolvedURL = new URL(this.#registryResolved(node.resolved)) + registry = new URL(pickRegistry(npa(packageName), this.options)) } catch { return false } + + if (resolvedURL.origin !== registry.origin) { + return false + } + + const registryPath = registry.pathname.replace(/\/?$/, '/') + if (registryPath === '/' || resolvedURL.pathname.startsWith(registryPath)) { + return true + } + + if (!node.version) { + return false + } + + // Some registries advertise tarballs from a sibling path on the same + // origin. Verify those URLs against registry metadata rather than + // widening the configured registry path boundary. + try { + const manifest = await pacote.manifest(npa.resolve(packageName, node.version), { + ...this.options, + before: null, + fullMetadata: true, + }) + const advertisedURL = new URL(this.#registryResolved(manifest._resolved)) + advertisedURL.hash = '' + resolvedURL.hash = '' + return advertisedURL.href === resolvedURL.href + } catch (error) { + log.verbose('reify', 'unable to verify registry tarball metadata', error) + return false + } } #registryResolved (resolved) { diff --git a/workspaces/arborist/lib/registry-package-name.js b/workspaces/arborist/lib/registry-package-name.js new file mode 100644 index 0000000000000..22e121e98f320 --- /dev/null +++ b/workspaces/arborist/lib/registry-package-name.js @@ -0,0 +1,44 @@ +const npa = require('npm-package-arg') +const { trustedSpecName } = require('./release-age-exclude.js') + +const carriedNames = new WeakMap() + +// Registry tarball exemptions widen fetch policy, so derive their package +// identity from valid dependency specs and require every such edge to agree. +const getRegistryPackageName = (node) => { + if (carriedNames.has(node)) { + return carriedNames.get(node) + } + if (!node.edgesIn || typeof node.edgesIn[Symbol.iterator] !== 'function') { + return null + } + + const names = new Set() + for (const edge of node.edgesIn) { + if (!edge.valid) { + continue + } + let spec + try { + spec = npa.resolve(edge.name, edge.spec) + } catch { + return null + } + if (!spec.registry) { + return null + } + const name = trustedSpecName(spec) + if (!name) { + return null + } + names.add(name) + } + + return names.size === 1 ? names.values().next().value : null +} + +const carryRegistryPackageName = (from, to) => { + carriedNames.set(to, getRegistryPackageName(from)) +} + +module.exports = { carryRegistryPackageName, getRegistryPackageName } diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index 943e340b22c1f..e9b00940d8b0e 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -3988,7 +3988,7 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { await t.resolves(arb.reify(), 'registry tarball under configured path is allowed') }) - t.test('allowRemote=none blocks same-origin tarball outside registry path', async t => { + t.test('allowRemote=none allows registry-advertised tarball outside registry path', async t => { const abbrevPackument5 = JSON.stringify({ _id: 'abbrev', _rev: 'lkjadflkjasdf', @@ -3999,7 +3999,7 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { name: 'abbrev', version: '1.1.1', dist: { - tarball: 'https://registry.example.com/evil/abbrev-1.1.1.tgz', + tarball: 'https://registry.example.com/download/abbrev-1.1.1.tgz', }, }, }, @@ -4021,14 +4021,439 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { .get('/npm/abbrev') .reply(200, abbrevPackument5) + tnock(t, 'https://registry.example.com') + .get('/download/abbrev-1.1.1.tgz') + .reply(200, abbrevTGZ) + const arb = new Arborist({ path: resolve(testdir, 'project'), registry: 'https://registry.example.com/npm/', cache: resolve(testdir, 'cache'), allowRemote: 'none', + packumentCache: new Map(), }) - await t.rejects(arb.reify(), { code: 'EALLOWREMOTE' }, 'sibling path tarball is blocked') + await t.resolves(arb.reify(), 'registry-advertised sibling-path tarball is allowed') + }) + + t.test('allowRemote=none verifies against dependency identity, not lockfile name', async t => { + const registryTarball = 'https://registry.example.com/npm/abbrev/-/abbrev-1.1.1.tgz' + const lockfileTarball = 'https://registry.example.com/evil/abbrev-1.1.1.tgz' + const packument = JSON.stringify({ + name: 'abbrev', + 'dist-tags': { latest: '1.1.1' }, + versions: { + '1.1.1': { + name: 'abbrev', + version: '1.1.1', + dist: { tarball: registryTarball }, + }, + }, + }) + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }, + 'node_modules/abbrev': { + name: 'lockfile-controlled-name', + version: '1.1.1', + resolved: lockfileTarball, + }, + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/npm/abbrev') + .reply(200, packument) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: 'https://registry.example.com/npm/', + cache: resolve(testdir, 'cache'), + allowRemote: 'none', + }) + + await t.rejects( + arb.reify(), + { code: 'EALLOWREMOTE' }, + 'lockfile-only sibling-path tarball is blocked' + ) + }) + + t.test('allowRemote=none reports EALLOWREMOTE when registry metadata is unavailable', async t => { + const tarballURL = 'https://registry.example.com/evil/abbrev-1.1.1.tgz' + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }, + 'node_modules/abbrev': { + version: '1.1.1', + resolved: tarballURL, + }, + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/npm/abbrev') + .reply(404, { error: 'metadata unavailable' }) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: 'https://registry.example.com/npm/', + cache: resolve(testdir, 'cache'), + allowRemote: 'none', + }) + + await t.rejects( + arb.reify(), + { code: 'EALLOWREMOTE' }, + 'failed verification falls back to the configured remote policy' + ) + }) + + t.test('allowRemote=none fails closed for unverifiable registry tarball shapes', async t => { + const cases = [ + { + label: 'non-registry provenance', + edgeSpec: '1.1.1', + invalidEdgeSpec: 'https://example.com/other-abbrev.tgz', + resolved: 'https://registry.example.com/download/abbrev-1.1.1.tgz', + }, + { + label: 'invalid target registry URL', + nodeName: 'alias', + packageName: '@scope/pkg', + edgeSpec: 'npm:@scope/pkg@1.0.0', + resolved: 'https://registry.example.com/download/pkg-1.0.0.tgz', + version: '1.0.0', + options: { '@scope:registry': 'not a registry URL' }, + }, + { + label: 'cross-origin tarball URL', + edgeSpec: '1.1.1', + resolved: 'https://cdn.example.com/abbrev-1.1.1.tgz', + }, + { + label: 'missing package version', + edgeSpec: '*', + resolved: 'https://registry.example.com/download/abbrev-1.1.1.tgz', + version: null, + }, + ] + + for (const testCase of cases) { + await t.test(testCase.label, async t => { + let extractOptions + let manifestCalls = 0 + const pacote = { + extract: async (spec, path, options) => { + extractOptions = options + }, + manifest: async () => { + manifestCalls++ + throw new Error('unexpected manifest request') + }, + } + const ArboristMock = t.mock('../../lib/arborist', { + ...mocks, + pacote, + }) + const path = t.testdir() + const nodeName = testCase.nodeName || 'abbrev' + const packageName = testCase.packageName || nodeName + const version = testCase.version === undefined ? '1.1.1' : testCase.version + const root = new Node({ + path, + pkg: { + name: 'project', + version: '1.0.0', + dependencies: { + [nodeName]: testCase.edgeSpec, + ...(testCase.invalidEdgeSpec ? { consumer: '1.0.0' } : {}), + }, + }, + }) + const node = new Node({ + name: nodeName, + resolved: testCase.resolved, + pkg: { + name: packageName, + ...(version ? { version } : {}), + }, + parent: root, + }) + if (testCase.invalidEdgeSpec) { + new Node({ + name: 'consumer', + pkg: { + name: 'consumer', + version: '1.0.0', + dependencies: { [nodeName]: testCase.invalidEdgeSpec }, + }, + parent: root, + }) + } + + const arb = new ArboristMock({ + audit: false, + path, + cache: path, + registry: 'https://registry.example.com/npm/', + allowRemote: 'none', + ...testCase.options, + }) + arb.addTracker('reify') + arb.idealTree = root + + await arb[Symbol.for('reifyNode')](node) + t.equal(extractOptions.allowRemote, 'none', 'does not widen the remote policy') + t.equal(manifestCalls, 0, 'does not request metadata for an unverifiable shape') + }) + } + }) + + for (const { label, allowRemote } of [ + { label: 'implicit all' }, + { label: 'all', allowRemote: 'all' }, + { label: 'root for a root dependency', allowRemote: 'root' }, + ]) { + t.test(`allowRemote=${label} does not verify an already-permitted registry tarball`, async t => { + const registryHost = `https://${label.replaceAll(' ', '-')}.example.com` + const tarballURL = `${registryHost}/evil/abbrev-1.1.1.tgz` + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }, + 'node_modules/abbrev': { + version: '1.1.1', + resolved: tarballURL, + }, + }, + }), + }, + }) + const packumentCache = new Map() + const cacheHas = packumentCache.has.bind(packumentCache) + let packumentChecks = 0 + packumentCache.has = key => { + packumentChecks++ + return cacheHas(key) + } + + tnock(t, registryHost) + .get('/evil/abbrev-1.1.1.tgz') + .reply(200, abbrevTGZ) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: `${registryHost}/npm/`, + cache: resolve(testdir, 'cache'), + packumentCache, + ...(allowRemote ? { allowRemote } : {}), + }) + + await t.resolves(arb.reify(), 'permitted tarball installs without registry metadata') + t.equal(packumentChecks, 0, 'does not consult the packument cache') + }) + } + + t.test('allowRemote=none uses the target scope for an aliased registry tarball', async t => { + const aliasName = '@alias/code-frame' + const packageName = '@babel/code-frame' + const version = '7.5.5' + const targetToken = 'target-token' + const aliasToken = 'alias-token' + const tarballURL = `https://registry.example.com/download/${packageName}/-/code-frame-${version}.tgz` + const packument = JSON.stringify({ + name: packageName, + 'dist-tags': { latest: version }, + versions: { + [version]: { + name: packageName, + version, + dist: { tarball: tarballURL }, + }, + }, + }) + const scopedTGZ = fs.readFileSync(resolve( + __dirname, + `../fixtures/registry-mocks/content/babel/code-frame/-/code-frame-${version}.tgz` + )) + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { + [aliasName]: `npm:${packageName}@${version}`, + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/npm/@babel%2fcode-frame') + .matchHeader('authorization', `Bearer ${targetToken}`) + .reply(200, packument) + + tnock(t, 'https://registry.example.com') + .get(`/download/${packageName}/-/code-frame-${version}.tgz`) + .matchHeader('authorization', `Bearer ${targetToken}`) + .reply(200, scopedTGZ) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: 'https://registry.npmjs.org/', + '@babel:registry': 'https://registry.example.com/npm/', + '@alias:registry': 'https://registry.example.com/alias/', + '//registry.example.com/npm/:_authToken': targetToken, + '//registry.example.com/alias/:_authToken': aliasToken, + cache: resolve(testdir, 'cache'), + allowRemote: 'none', + packumentCache: new Map(), + }) + + await t.resolves(arb.reify(), 'aliased scoped registry tarball is allowed') + const installed = JSON.parse(fs.readFileSync( + resolve(testdir, 'project/node_modules', aliasName, 'package.json'), + 'utf8' + )) + t.equal(installed.name, packageName, 'target package is installed in the alias slot') + }) + + t.test('allowRemote=root verifies a locked transitive alias using its target scope', async t => { + const packageName = '@babel/code-frame' + const version = '7.5.5' + const token = 'target-token' + const registryHost = 'https://registry.example.com' + const abbrevTarball = `${registryHost}/npm/abbrev/-/abbrev-1.1.1.tgz` + const advertisedAliasTarball = `${registryHost}/download/${packageName}/-/code-frame-${version}.tgz` + const lockedAliasTarball = `${advertisedAliasTarball}#lockfile-integrity` + const packument = JSON.stringify({ + name: packageName, + 'dist-tags': { latest: version }, + versions: { + [version]: { + name: packageName, + version, + dist: { tarball: advertisedAliasTarball }, + }, + }, + }) + const scopedTGZ = fs.readFileSync(resolve( + __dirname, + `../fixtures/registry-mocks/content/babel/code-frame/-/code-frame-${version}.tgz` + )) + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'myproject', + version: '1.0.0', + dependencies: { abbrev: '1.1.1' }, + }, + 'node_modules/abbrev': { + version: '1.1.1', + resolved: abbrevTarball, + dependencies: { + hoek: `npm:${packageName}@${version}`, + }, + }, + 'node_modules/hoek': { + name: packageName, + version, + resolved: lockedAliasTarball, + }, + }, + }), + }, + }) + + tnock(t, registryHost) + .get('/npm/abbrev/-/abbrev-1.1.1.tgz') + .reply(200, abbrevTGZ) + + tnock(t, registryHost) + .get('/scoped/@babel%2fcode-frame') + .matchHeader('authorization', `Bearer ${token}`) + .reply(200, packument) + + tnock(t, registryHost) + .get(`/download/${packageName}/-/code-frame-${version}.tgz`) + .matchHeader('authorization', `Bearer ${token}`) + .reply(200, scopedTGZ) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: `${registryHost}/npm/`, + '@babel:registry': `${registryHost}/scoped/`, + '//registry.example.com/scoped/:_authToken': token, + cache: resolve(testdir, 'cache'), + allowRemote: 'root', + }) + + await t.resolves(arb.reify(), 'transitive registry alias is exempted rather than treated as root') + const installed = JSON.parse(fs.readFileSync( + resolve(testdir, 'project/node_modules/hoek/package.json'), + 'utf8' + )) + t.equal(installed.name, packageName, 'locked alias retains its target package identity') }) t.test('allowRemote=none allows same-origin tarball for root registry path', async t => { @@ -4178,7 +4603,7 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { await t.resolves(arb.reify(), 'registry tarball routed through the configured registry is allowed') }) - t.test('allowRemote=none allows registry tarball under linked install strategy', async t => { + t.test('allowRemote=none allows registry-advertised tarball under linked strategy', async t => { // The linked strategy extracts store nodes as IsolatedNode, which has no edges to recompute isRegistryDependency from. // The flag must be carried from the source tree node so the registry-tarball allow-remote exemption still applies. const abbrevPackument5 = JSON.stringify({ @@ -4191,7 +4616,7 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { name: 'abbrev', version: '1.1.1', dist: { - tarball: 'https://registry.example.com/npm/abbrev/-/abbrev-1.1.1.tgz', + tarball: 'https://registry.example.com/download/abbrev-1.1.1.tgz', }, }, }, @@ -4214,7 +4639,7 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { .reply(200, abbrevPackument5) tnock(t, 'https://registry.example.com') - .get('/npm/abbrev/-/abbrev-1.1.1.tgz') + .get('/download/abbrev-1.1.1.tgz') .reply(200, abbrevTGZ) const arb = new Arborist({ @@ -4223,9 +4648,10 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { cache: resolve(testdir, 'cache'), allowRemote: 'none', installStrategy: 'linked', + packumentCache: new Map(), }) - await t.resolves(arb.reify(), 'registry tarball is allowed under linked strategy') + await t.resolves(arb.reify(), 'verified sibling-path tarball is allowed under linked strategy') }) t.test('allowRemote=root allows root-direct remote tarball under linked install strategy', async t => { diff --git a/workspaces/arborist/test/registry-package-name.js b/workspaces/arborist/test/registry-package-name.js new file mode 100644 index 0000000000000..4c0f44477e057 --- /dev/null +++ b/workspaces/arborist/test/registry-package-name.js @@ -0,0 +1,83 @@ +const t = require('tap') +const { + carryRegistryPackageName, + getRegistryPackageName, +} = require('../lib/registry-package-name.js') + +const edge = (name, spec, valid = true) => ({ name, spec, valid }) +const node = (...edgesIn) => ({ edgesIn: new Set(edgesIn) }) + +t.equal( + getRegistryPackageName(node(edge('abbrev', '^1.0.0'))), + 'abbrev', + 'uses the dependency name for a registry range' +) + +t.equal( + getRegistryPackageName(node(edge('hoek', 'npm:@npm/hoek@6.1.4'))), + '@npm/hoek', + 'uses the target package name for an alias' +) + +t.equal( + getRegistryPackageName(node( + edge('hoek', 'npm:@npm/hoek@6.1.4'), + edge('hoek', 'npm:@other/hoek@6.1.4', false) + )), + '@npm/hoek', + 'ignores invalid inbound edges' +) + +t.equal( + getRegistryPackageName(node( + edge('hoek', 'npm:@npm/hoek@6.1.4'), + edge('hoek', 'npm:@other/hoek@6.1.4') + )), + null, + 'rejects conflicting valid registry identities' +) + +t.equal( + getRegistryPackageName(node(edge('pkg', 'https://example.com/pkg.tgz'))), + null, + 'rejects non-registry dependency specs' +) + +t.equal( + getRegistryPackageName(node(edge('pkg', 'npm:'))), + null, + 'rejects invalid dependency specs' +) + +t.equal( + getRegistryPackageName(node(edge(undefined, '1.0.0'))), + null, + 'rejects registry specs without a package name' +) + +t.equal( + getRegistryPackageName({}), + null, + 'rejects nodes without inbound edges' +) + +t.equal( + getRegistryPackageName({ edgesIn: {} }), + null, + 'rejects nodes whose inbound edges are not iterable' +) + +t.equal( + getRegistryPackageName(node()), + null, + 'rejects nodes without a valid inbound identity' +) + +const source = node(edge('hoek', 'npm:@npm/hoek@6.1.4')) +const isolated = node() +carryRegistryPackageName(source, isolated) +t.equal( + getRegistryPackageName(isolated), + '@npm/hoek', + 'carries the trusted identity to an isolated node' +) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index f932d8f48103c..3c9e8b799f323 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -253,13 +253,13 @@ const definitions = { Please note that this could leave your tree incomplete and some packages may not function as intended or designed. Changing this setting will not remove dependencies that are already installed. - As of npm 12 the default is \`none\`. Tarballs that share a hostname - with the configured registry (the typical case for the npm registry, - GitHub Packages, and most private registries) are still installed - normally. If your registry serves tarballs from a different host, - set \`replace-registry-host\` or override this setting. Opt in - explicitly per project (in \`.npmrc\`) or per command (on the CLI) - when you intentionally install from a URL. + As of npm 12 the default is \`none\`. Tarballs under the configured + registry path are installed normally. npm also permits same-origin + tarballs when it can verify their exact URL against registry metadata. + If your registry serves tarballs from a different host, set + \`replace-registry-host\` or override this setting. Opt in explicitly + per project (in \`.npmrc\`) or per command (on the CLI) when you + intentionally install from a URL. \`all\` allows any url to be installed. \`none\` prevents any url from being installed.