Skip to content

Commit 4bcba54

Browse files
fix(arborist): apply registry-tarball allow-remote exemption in linked strategy (#9495)
In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](WordPress/gutenberg#75814), which powers the WordPress Block Editor. Under `install-strategy=linked`, a fresh install fails with `EALLOWREMOTE` on ordinary registry dependencies whose lockfile `resolved` is a full registry tarball URL, even though `allow-remote=none` is meant to permit registry-mediated tarballs. The standard (hoisted) reifier installs the same dependency fine; only the linked strategy rejects it. ``` npm error code EALLOWREMOTE npm error Fetching packages of type "remote" have been disabled npm error Refusing to fetch "minimatch@https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz" ``` ## Why Both strategies extract through the same `pacote.extract` in `reify.js`, which exempts registry tarballs from the allow-remote gate via `#isRegistryResolvedTarball`. That check first requires `node.isRegistryDependency`. In the linked strategy, store nodes are `IsolatedNode` instances — a standalone class that emulates `lib/node.js` but has no `isRegistryDependency` getter and no edges to recompute it from. So `node.isRegistryDependency` was `undefined`, the exemption short-circuited to `false`, the `allowRemote: 'all'` override was never applied, and pacote rejected the same-origin registry tarball. This is the second half of the allow-remote registry-tarball handling: the URL-matching half was hardened previously (origin + registry-path-prefix); this fixes the `isRegistryDependency` half for the linked path. The origin/path security check still runs unchanged on the linked path — a tampered lockfile pointing at a foreign host is still blocked. ## How Carry the registry-dependency flag from the source tree node onto the store node, rather than weakening the guard: 1. `IsolatedNode` gains an `isRegistryDependency` field (default `false`), settable from constructor options. 2. `#externalProxy` copies `node.isRegistryDependency` from the real tree node onto the proxy. 3. `#generateChild` passes it through to the store `IsolatedNode`. This preserves exact parity with the hoisted reifier: registry deps are exempt, user-pinned off-registry URLs are not. It also makes the linked strategy's `isScriptAllowed` matching more accurate — store nodes now carry the trustworthy edge-based flag instead of falling back to guessing registry-ness from the resolved URL. ## References Fixes #9494
1 parent 0e55f97 commit 4bcba54

4 files changed

Lines changed: 59 additions & 0 deletions

File tree

workspaces/arborist/lib/arborist/isolated-reifier.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ module.exports = cls => class IsolatedReifier extends cls {
4242
const newChild = new IsolatedNode({
4343
isInStore,
4444
inBundle,
45+
isRegistryDependency: node.isRegistryDependency,
4546
location,
4647
name: node.packageName || node.name,
4748
optional: node.optional,
@@ -153,6 +154,9 @@ module.exports = cls => class IsolatedReifier extends cls {
153154
result.optional = node.optional
154155
result.resolved = node.resolved
155156
result.version = node.version
157+
// Carry the source node's registry-dependency flag so the store node retains it.
158+
// IsolatedNode has no edges to recompute it from, and reify's registry-tarball allow-remote exemption depends on it.
159+
result.isRegistryDependency = node.isRegistryDependency
156160
return result
157161
}
158162

workspaces/arborist/lib/isolated-classes.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class IsolatedNode {
2020
inventory = new IsolatedInventory()
2121
isInStore = false
2222
inBundle = false
23+
isRegistryDependency = false
2324
linksIn = new Set()
2425
meta = { loadedFromDisk: false }
2526
optional = false
@@ -50,6 +51,9 @@ class IsolatedNode {
5051
if (options.inBundle) {
5152
this.inBundle = true
5253
}
54+
if (options.isRegistryDependency) {
55+
this.isRegistryDependency = true
56+
}
5357
if (options.optional) {
5458
this.optional = true
5559
}

workspaces/arborist/test/arborist/reify.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3854,6 +3854,56 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => {
38543854
await t.resolves(arb.reify(), 'same-origin tarball is allowed for registry root')
38553855
})
38563856

3857+
t.test('allowRemote=none allows registry tarball under linked install strategy', async t => {
3858+
// The linked strategy extracts store nodes as IsolatedNode, which has no edges to recompute isRegistryDependency from.
3859+
// The flag must be carried from the source tree node so the registry-tarball allow-remote exemption still applies.
3860+
const abbrevPackument5 = JSON.stringify({
3861+
_id: 'abbrev',
3862+
_rev: 'lkjadflkjasdf',
3863+
name: 'abbrev',
3864+
'dist-tags': { latest: '1.1.1' },
3865+
versions: {
3866+
'1.1.1': {
3867+
name: 'abbrev',
3868+
version: '1.1.1',
3869+
dist: {
3870+
tarball: 'https://registry.example.com/npm/abbrev/-/abbrev-1.1.1.tgz',
3871+
},
3872+
},
3873+
},
3874+
})
3875+
3876+
const testdir = t.testdir({
3877+
project: {
3878+
'package.json': JSON.stringify({
3879+
name: 'myproject',
3880+
version: '1.0.0',
3881+
dependencies: {
3882+
abbrev: '1.1.1',
3883+
},
3884+
}),
3885+
},
3886+
})
3887+
3888+
tnock(t, 'https://registry.example.com')
3889+
.get('/npm/abbrev')
3890+
.reply(200, abbrevPackument5)
3891+
3892+
tnock(t, 'https://registry.example.com')
3893+
.get('/npm/abbrev/-/abbrev-1.1.1.tgz')
3894+
.reply(200, abbrevTGZ)
3895+
3896+
const arb = new Arborist({
3897+
path: resolve(testdir, 'project'),
3898+
registry: 'https://registry.example.com/npm',
3899+
cache: resolve(testdir, 'cache'),
3900+
allowRemote: 'none',
3901+
installStrategy: 'linked',
3902+
})
3903+
3904+
await t.resolves(arb.reify(), 'registry tarball is allowed under linked strategy')
3905+
})
3906+
38573907
t.test('registry with different protocol should swap protocol', async (t) => {
38583908
const abbrevPackument4 = JSON.stringify({
38593909
_id: 'abbrev',

workspaces/arborist/test/script-allowed.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ t.test('isolated mode (linked): bundled IsolatedNode is blocked', async t => {
452452

453453
const store = new IsolatedNode({
454454
isInStore: true,
455+
isRegistryDependency: true, // carried from the source node by #externalProxy
455456
location: 'node_modules/.store/store-pkg@1.0.0/node_modules/store-pkg',
456457
name: 'store-pkg',
457458
package: { name: 'store-pkg', version: '1.0.0' },

0 commit comments

Comments
 (0)