From 80d887364ea282dcb8dacce3864cb72aab273028 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 02:39:51 +0530 Subject: [PATCH] fix(arborist): allow-remote exemption for proxy/mirror-fronted registry tarballs (#9550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `allow-remote=none` (the default on npm 12) or `allow-remote=root`, a `npm install` fails with `EALLOWREMOTE` on ordinary registry dependencies when the configured registry origin differs from the lockfile `resolved` origin. ``` npm error code EALLOWREMOTE npm error Fetching packages of type "remote" have been disabled ``` This is the common proxy/mirror case: a committed `package-lock.json` whose `resolved` URLs point to `https://registry.npmjs.org/...`, while the machine (or CI) is configured to use a private registry proxy/mirror with a different origin. It affects both the hoisted and the linked install strategy. ## Why When extracting a registry-resolved package, reify hands pacote a `name@URL` spec, which pacote re-parses as `type=remote` and gates with allow-remote. To avoid mis-firing on registry tarballs, `#isRegistryResolvedTarball` exempts them — but it compared the raw lockfile `resolved` URL against the configured registry origin. With a proxy/mirror configured, `resolved` is the canonical `registry.npmjs.org` URL while the configured registry is the proxy, so the origins never matched, the exemption returned `false`, and the registry tarball was rejected as remote. Crucially, reify already fetches a different URL than the raw `resolved`: `#registryResolved` applies `replace-registry-host` (default `npmjs`), rewriting the `registry.npmjs.org` host to the configured registry while preserving the path. So npm fetches the tarball from the proxy correctly; only the allow-remote check was evaluating the wrong (pre-rewrite) URL. ## How Evaluate the effective URL npm actually fetches, not the raw lockfile value. `#isRegistryResolvedTarball` now parses `this.#registryResolved(node.resolved)` — the host-rewritten URL — before the same origin + registry-path-prefix comparison. After rewriting, a public-registry-pinned tarball resolves to the configured registry and is correctly recognized as registry-mediated. The existing security boundary is preserved: under the default `replace-registry-host`, a same-origin tarball pointing outside the registry path is not rewritten and is still rejected, and a genuinely URL-declared dependency still fails the `node.isRegistryDependency` guard. Under `replace-registry-host=always`, every tarball is routed through the configured registry, so registry dependencies are no longer treated as remote — consistent with what `always` means. ## References Fixes #9548 (cherry picked from commit 8bbd70d939cec237b4e045993955895971c9eb85) --- workspaces/arborist/lib/arborist/reify.js | 8 +- workspaces/arborist/test/arborist/reify.js | 100 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index c49f352cef140..15d499e7fb5d9 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -888,12 +888,14 @@ module.exports = cls => class Reifier extends cls { return false } try { - const resolved = new URL(node.resolved) + // 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 resolved.origin === registry.origin && - (registryPath === '/' || resolved.pathname.startsWith(registryPath)) + return resolvedURL.origin === registry.origin && + (registryPath === '/' || resolvedURL.pathname.startsWith(registryPath)) } catch { return false } diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index d80d4d2998758..87144c3efb474 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -4027,6 +4027,106 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { await t.resolves(arb.reify(), 'same-origin tarball is allowed for registry root') }) + t.test('allowRemote=none allows registry tarball whose resolved origin differs from the configured registry', async t => { + // Proxy/mirror case: a committed lockfile pins resolved to the public registry while a private mirror is configured. + // replace-registry-host rewrites the host to the configured registry at fetch time, so the effective URL is registry-mediated and must pass allow-remote=none. + const abbrevPackumentNpmjs = JSON.stringify({ + _id: 'abbrev', + _rev: 'lkjadflkjasdf', + name: 'abbrev', + 'dist-tags': { latest: '1.1.1' }, + versions: { + '1.1.1': { + name: 'abbrev', + version: '1.1.1', + dist: { + tarball: 'https://registry.npmjs.org/abbrev/-/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', + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/abbrev') + .reply(200, abbrevPackumentNpmjs) + + // replace-registry-host (default 'npmjs') rewrites the npmjs.org tarball host to the configured mirror, so the fetch lands here. + tnock(t, 'https://registry.example.com') + .get('/abbrev/-/abbrev-1.1.1.tgz') + .reply(200, abbrevTGZ) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: 'https://registry.example.com', + cache: resolve(testdir, 'cache'), + allowRemote: 'none', + }) + + await t.resolves(arb.reify(), 'mirror-fronted registry tarball is allowed under allow-remote=none') + }) + + t.test('allowRemote=none allows registry tarball with replaceRegistryHost=always', async t => { + // replace-registry-host=always routes every registry tarball fetch through the configured registry, so the effective URL is never remote and must pass allow-remote=none. + const abbrevPackumentNpmjs = JSON.stringify({ + _id: 'abbrev', + _rev: 'lkjadflkjasdf', + name: 'abbrev', + 'dist-tags': { latest: '1.1.1' }, + versions: { + '1.1.1': { + name: 'abbrev', + version: '1.1.1', + dist: { + tarball: 'https://registry.npmjs.org/abbrev/-/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', + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/npm/abbrev') + .reply(200, abbrevPackumentNpmjs) + + // always rewrites the tarball host to the configured registry and prepends the registry path. + tnock(t, 'https://registry.example.com') + .get('/npm/abbrev/-/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', + replaceRegistryHost: 'always', + }) + + 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 => { // 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.