Skip to content

Commit 70b3763

Browse files
committed
fix(arborist): add packageName getter to IsolatedNode for path-safe names
The IsolatedNode class now derives packageName from the filesystem path via nameFromFolder, preventing path traversal when node.name contains directory separators. The #assignCommonProperties fallback is changed from node.name to nameFromFolder(node.path) for proxy objects that don't use the getter.
1 parent 8afa3bd commit 70b3763

3 files changed

Lines changed: 93 additions & 1 deletion

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { join } = require('node:path')
44
const { depth } = require('treeverse')
55
const crypto = require('node:crypto')
66
const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
7+
const nameFromFolder = require('@npmcli/name-from-folder')
78

89
// generate short hash key based on the dependency tree starting at this node
910
const getKey = (startNode) => {
@@ -191,7 +192,7 @@ module.exports = cls => class IsolatedReifier extends cls {
191192
result.id = this.counter++
192193
/* istanbul ignore next - packageName is always set for real packages */
193194
result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
194-
result.packageName = node.packageName || node.name
195+
result.packageName = node.packageName || nameFromFolder(node.path)
195196
result.package = { ...node.package }
196197
result.package.bundleDependencies = undefined
197198

workspaces/arborist/lib/isolated-classes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Alternate versions of different classes that we use for isolated mode
22
const CaseInsensitiveMap = require('./case-insensitive-map.js')
33
const { resolve } = require('node:path')
4+
const nameFromFolder = require('@npmcli/name-from-folder')
45

56
// fake lib/inventory.js
67
class IsolatedInventory extends Map {
@@ -104,6 +105,10 @@ class IsolatedNode {
104105
return !!(hasInstallScript || install || preinstall || postinstall)
105106
}
106107

108+
get packageName () {
109+
return nameFromFolder(this.path) || nameFromFolder(this.name) || this.name
110+
}
111+
107112
get version () {
108113
return this.package.version
109114
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
const t = require('tap')
2+
const path = require('node:path')
3+
const { IsolatedNode, IsolatedLink } = require('../lib/isolated-classes.js')
4+
5+
t.test('IsolatedNode packageName getter', t => {
6+
t.test('derives name from path', t => {
7+
const node = new IsolatedNode({
8+
path: path.join('/some', 'node_modules', 'my-package'),
9+
name: 'my-package',
10+
package: { version: '1.0.0' },
11+
})
12+
t.equal(node.packageName, 'my-package')
13+
t.end()
14+
})
15+
16+
t.test('path traversal in name is neutralized when path is set', t => {
17+
const node = new IsolatedNode({
18+
path: path.join('/some', 'node_modules', 'my-package'),
19+
name: '../../my-package',
20+
package: { version: '1.0.0' },
21+
})
22+
t.equal(node.packageName, 'my-package')
23+
t.not(node.packageName, '../../my-package',
24+
'must not return unsanitized name containing traversal')
25+
t.end()
26+
})
27+
28+
t.test('path traversal in name is neutralized even without path', t => {
29+
const node = new IsolatedNode({
30+
name: '../../evil',
31+
package: { version: '1.0.0' },
32+
})
33+
t.equal(node.packageName, 'evil',
34+
'nameFromFolder strips traversal from name as fallback')
35+
t.not(node.packageName, '../../evil')
36+
t.end()
37+
})
38+
39+
t.test('handles scoped packages via path', t => {
40+
const node = new IsolatedNode({
41+
path: path.join('/some', 'node_modules', '@scope', 'pkg'),
42+
name: '@scope/pkg',
43+
package: { version: '2.0.0' },
44+
})
45+
t.equal(node.packageName, '@scope/pkg')
46+
t.end()
47+
})
48+
49+
t.test('handles scoped packages via name fallback', t => {
50+
const node = new IsolatedNode({
51+
name: '@scope/pkg',
52+
package: { version: '2.0.0' },
53+
})
54+
t.equal(node.packageName, '@scope/pkg',
55+
'nameFromFolder preserves scoped names when used as fallback')
56+
t.end()
57+
})
58+
59+
t.test('falls back to raw name as last resort', t => {
60+
const node = new IsolatedNode({
61+
name: 'simple-name',
62+
package: { version: '1.0.0' },
63+
})
64+
t.equal(node.packageName, 'simple-name')
65+
t.end()
66+
})
67+
68+
t.end()
69+
})
70+
71+
t.test('IsolatedLink inherits packageName getter', t => {
72+
const target = new IsolatedNode({
73+
path: path.join('/some', 'node_modules', 'pkg'),
74+
name: 'pkg',
75+
package: { version: '1.0.0' },
76+
})
77+
const link = new IsolatedLink({
78+
path: path.join('/some', 'node_modules', '.store', 'pkg@1.0.0', 'node_modules', 'pkg'),
79+
name: 'pkg',
80+
package: { version: '1.0.0' },
81+
realpath: target.path,
82+
target,
83+
})
84+
t.equal(link.packageName, 'pkg')
85+
t.end()
86+
})

0 commit comments

Comments
 (0)