Skip to content

Commit 39d034d

Browse files
committed
fix: sanitize package name in linked-strategy path construction
(cherry picked from commit 9f3c97f)
1 parent 1bb62bb commit 39d034d

3 files changed

Lines changed: 134 additions & 2 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
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) => {
@@ -151,11 +152,14 @@ module.exports = cls => class IsolatedReifier extends cls {
151152
this.#externalProxies.set(node, result)
152153
await this.#assignCommonProperties(node, result, !node.hasShrinkwrap)
153154
if (node.hasShrinkwrap) {
155+
// strip any path traversal from package.json name fields before they hit path.join below
156+
/* istanbul ignore next - packageName is always set for real packages */
157+
const safeName = nameFromFolder(node.packageName || node.path)
154158
const dir = join(
155159
node.root.path,
156160
'node_modules',
157161
'.store',
158-
`${node.packageName}@${node.version}`
162+
`${safeName}@${node.version}`
159163
)
160164
mkdirSync(dir, { recursive: true })
161165
// TODO this approach feels wrong and shouldn't be necessary for shrinkwraps
@@ -198,7 +202,8 @@ module.exports = cls => class IsolatedReifier extends cls {
198202
result.id = this.counter++
199203
/* istanbul ignore next - packageName is always set for real packages */
200204
result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
201-
result.packageName = node.packageName || node.name
205+
// strip any path traversal from package.json name fields before they hit path.join below
206+
result.packageName = nameFromFolder(node.packageName || node.path)
202207
result.package = { ...node.package }
203208
result.package.bundleDependencies = undefined
204209

workspaces/arborist/lib/isolated-classes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ class IsolatedNode {
104104
return !!(hasInstallScript || install || preinstall || postinstall)
105105
}
106106

107+
/* istanbul ignore next -- emulate lib/node.js */
108+
get packageName () {
109+
return this.package.name || null
110+
}
111+
107112
get version () {
108113
return this.package.version
109114
}

workspaces/arborist/test/arborist/reify.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4065,6 +4065,128 @@ t.test('workspace installs retain existing versions with newer package specs', a
40654065
'another-cool-package package.json should be updated to abbrev@1.0.4')
40664066
})
40674067

4068+
for (const poisoned of ['../../../escape-target', '@evil/../../../escape-target']) {
4069+
t.test(`install strategy linked sanitizes traversal in lockfile name (${poisoned})`, async t => {
4070+
// a poisoned lockfile name field would otherwise escape node_modules/.store
4071+
const testDir = t.testdir({
4072+
'package.json': JSON.stringify({
4073+
dependencies: {
4074+
abbrev: '1.1.1',
4075+
},
4076+
}),
4077+
'package-lock.json': JSON.stringify({
4078+
lockfileVersion: 3,
4079+
requires: true,
4080+
packages: {
4081+
'': {
4082+
dependencies: {
4083+
abbrev: '1.1.1',
4084+
},
4085+
},
4086+
'node_modules/abbrev': {
4087+
name: poisoned,
4088+
version: '1.1.1',
4089+
resolved: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz',
4090+
integrity: 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==',
4091+
},
4092+
},
4093+
}),
4094+
})
4095+
4096+
const arb = new Arborist({
4097+
path: testDir,
4098+
registry: 'https://registry.npmjs.org',
4099+
cache: resolve(testDir, 'cache'),
4100+
installStrategy: 'linked',
4101+
packageLockOnly: true,
4102+
})
4103+
await arb.reify({ installStrategy: 'linked', packageLockOnly: true })
4104+
4105+
const external = arb.idealGraph.external
4106+
t.equal(external.length, 1, 'one external dep planned')
4107+
4108+
const pkgName = external[0].packageName
4109+
t.notMatch(pkgName, /\.\./, 'packageName has no traversal segments')
4110+
t.ok(!pkgName.includes('/') || pkgName.startsWith('@'),
4111+
'packageName is a single segment (or @scope/name)')
4112+
4113+
// joining the sanitized name into the .store layout must not escape
4114+
const storePrefix = resolve(testDir, 'node_modules/.store/key/node_modules')
4115+
const projected = resolve(storePrefix, pkgName)
4116+
t.ok(projected.startsWith(storePrefix), 'projected path stays inside .store')
4117+
4118+
// belt-and-suspenders: nothing should have been written outside testDir,
4119+
// even if a future change starts materializing paths during reify
4120+
t.notOk(fs.existsSync(resolve(testDir, '..', 'escape-target')),
4121+
'no escape-target leaked one level above testDir')
4122+
t.notOk(fs.existsSync(resolve(testDir, '..', '..', 'escape-target')),
4123+
'no escape-target leaked two levels above testDir')
4124+
t.notOk(fs.existsSync(resolve(testDir, '..', '..', '..', 'escape-target')),
4125+
'no escape-target leaked three levels above testDir')
4126+
})
4127+
}
4128+
4129+
for (const poisoned of ['../../../escape-target', '@evil/../../../escape-target']) {
4130+
t.test(`install strategy linked sanitizes traversal in shrinkwrapped lockfile name (${poisoned})`, async t => {
4131+
// the hasShrinkwrap branch in #externalProxy materializes
4132+
// node_modules/.store/<name>@<version> via mkdirSync before any other
4133+
// check, so a poisoned name in a shrinkwrapped entry would escape
4134+
// node_modules/.store on disk without sanitization
4135+
const testDir = t.testdir({
4136+
'package.json': JSON.stringify({
4137+
dependencies: {
4138+
abbrev: '1.1.1',
4139+
},
4140+
}),
4141+
'package-lock.json': JSON.stringify({
4142+
lockfileVersion: 3,
4143+
requires: true,
4144+
packages: {
4145+
'': {
4146+
dependencies: {
4147+
abbrev: '1.1.1',
4148+
},
4149+
},
4150+
'node_modules/abbrev': {
4151+
name: poisoned,
4152+
version: '1.1.1',
4153+
hasShrinkwrap: true,
4154+
resolved: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz',
4155+
integrity: 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==',
4156+
},
4157+
},
4158+
}),
4159+
})
4160+
4161+
const arb = new Arborist({
4162+
path: testDir,
4163+
registry: 'https://registry.npmjs.org',
4164+
cache: resolve(testDir, 'cache'),
4165+
installStrategy: 'linked',
4166+
packageLockOnly: true,
4167+
})
4168+
// pacote.extract may fail in this offline test setup; the mkdirSync that
4169+
// runs before it is what we care about and runs unconditionally
4170+
try {
4171+
await arb.reify({ installStrategy: 'linked', packageLockOnly: true })
4172+
} catch {
4173+
// expected in offline test setup
4174+
}
4175+
4176+
// the sanitized store entry must land inside testDir/node_modules/.store
4177+
t.ok(fs.existsSync(resolve(testDir, 'node_modules/.store/escape-target@1.1.1')),
4178+
'sanitized .store/escape-target@1.1.1 created inside the project')
4179+
4180+
// and no escape-target@1.1.1 directory should appear above testDir
4181+
t.notOk(fs.existsSync(resolve(testDir, '..', 'escape-target@1.1.1')),
4182+
'no escape-target@1.1.1 leaked one level above testDir')
4183+
t.notOk(fs.existsSync(resolve(testDir, '..', '..', 'escape-target@1.1.1')),
4184+
'no escape-target@1.1.1 leaked two levels above testDir')
4185+
t.notOk(fs.existsSync(resolve(testDir, '..', '..', '..', 'escape-target@1.1.1')),
4186+
'no escape-target@1.1.1 leaked three levels above testDir')
4187+
})
4188+
}
4189+
40684190
t.test('externalOptionalDependencies excludes inert optional node with installStrategy linked', async t => {
40694191
const testDir = t.testdir({
40704192
'package.json': JSON.stringify({

0 commit comments

Comments
 (0)