mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
fix: prevent path traversal in directories.bin (#10495)
by validating the bin directory is a subdirectory of the package root and adding relevant tests.
This commit is contained in:
6
.changeset/fix-directories-bin-path-traversal.md
Normal file
6
.changeset/fix-directories-bin-path-traversal.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/package-bins": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Security fix: prevent path traversal in `directories.bin` field.
|
||||
@@ -39,7 +39,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/package-bins": "workspace:*",
|
||||
"@types/node": "catalog:"
|
||||
"@types/node": "catalog:",
|
||||
"tempy": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
|
||||
@@ -14,6 +14,10 @@ export async function getBinsFromPackageManifest (manifest: DependencyManifest,
|
||||
}
|
||||
if (manifest.directories?.bin) {
|
||||
const binDir = path.join(pkgPath, manifest.directories.bin)
|
||||
// Validate: directories.bin must be within the package root
|
||||
if (!isSubdir(pkgPath, binDir)) {
|
||||
return []
|
||||
}
|
||||
const files = await findFiles(binDir)
|
||||
return files.map((file) => ({
|
||||
name: path.basename(file),
|
||||
|
||||
@@ -144,3 +144,25 @@ test('skip scoped bin names with path traversal', async () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('skip directories.bin with path traversal', async () => {
|
||||
// Security test: malicious packages can try to escape the package root
|
||||
// using directories.bin to chmod files at arbitrary locations
|
||||
expect(
|
||||
await getBinsFromPackageManifest({
|
||||
name: 'malicious',
|
||||
version: '1.0.0',
|
||||
directories: {
|
||||
bin: '../../../../tmp/target',
|
||||
},
|
||||
}, process.cwd())).toStrictEqual([])
|
||||
|
||||
expect(
|
||||
await getBinsFromPackageManifest({
|
||||
name: 'malicious',
|
||||
version: '1.0.0',
|
||||
directories: {
|
||||
bin: '../../../etc',
|
||||
},
|
||||
}, process.cwd())).toStrictEqual([])
|
||||
})
|
||||
|
||||
32
pkg-manager/package-bins/test/path-traversal.test.ts
Normal file
32
pkg-manager/package-bins/test/path-traversal.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getBinsFromPackageManifest } from '@pnpm/package-bins'
|
||||
import { temporaryDirectory } from 'tempy'
|
||||
|
||||
test('skip directories.bin with real path traversal', async () => {
|
||||
// Create a secret file outside the package directory
|
||||
const tempDir = temporaryDirectory()
|
||||
const secretDir = path.join(tempDir, 'secret')
|
||||
fs.mkdirSync(secretDir)
|
||||
fs.writeFileSync(path.join(secretDir, 'secret.sh'), 'echo secret')
|
||||
|
||||
// Create a package directory
|
||||
const pkgDir = path.join(tempDir, 'pkg')
|
||||
fs.mkdirSync(pkgDir)
|
||||
|
||||
// Calculate relative path from pkgDir to secretDir
|
||||
const relativePath = path.relative(pkgDir, secretDir)
|
||||
|
||||
// Attempt path traversal
|
||||
const bins = await getBinsFromPackageManifest({
|
||||
name: 'malicious',
|
||||
version: '1.0.0',
|
||||
directories: {
|
||||
bin: relativePath,
|
||||
},
|
||||
}, pkgDir)
|
||||
|
||||
// Should be empty because it escaped pkgDir
|
||||
expect(bins).toStrictEqual([])
|
||||
})
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -5715,6 +5715,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 22.15.30
|
||||
tempy:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.0
|
||||
|
||||
pkg-manager/package-requester:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user