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:
Zoltan Kochan
2026-01-21 15:46:41 +01:00
committed by GitHub
parent 2ea64631eb
commit 13855aca86
6 changed files with 69 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/package-bins": patch
"pnpm": patch
---
Security fix: prevent path traversal in `directories.bin` field.

View File

@@ -39,7 +39,8 @@
},
"devDependencies": {
"@pnpm/package-bins": "workspace:*",
"@types/node": "catalog:"
"@types/node": "catalog:",
"tempy": "catalog:"
},
"engines": {
"node": ">=20.19"

View File

@@ -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),

View 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([])
})

View 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
View File

@@ -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: