Skip to content

Commit bee4bf4

Browse files
authored
fix: reject path-traversal config dependency names from the env lockfile (#12470)
Config dependency names and versions are read from the committed env lockfile (pnpm-lock.yaml) and the legacy inline-integrity format in pnpm-workspace.yaml, and both become path segments of the directories pnpm creates during install (node_modules/.pnpm-config/<name> and the global virtual store's <name>/<version>/<hash>). They were used unvalidated, so a malicious repository could commit a traversal-shaped name (../../PWNED) or version (../../../PWNED) and make `pnpm install` create symlinks or write package files outside those roots — triggered on install, even with --ignore-scripts. Add verifyEnvLockfile, an offline structural gate that validates every config dependency and optional-subdependency name (must be a valid npm package name) and version (must be an exact semver version) before any path is built from it. It runs at the install boundary and, through a single writeVerifiedEnvLockfile seam, before the env lockfile is ever persisted, so an invalid entry is rejected with no write side effect. __proto__ names are rejected too (the validation accumulators use null-prototype objects so the key can't slip past Object.keys). The same fix and structure land in pacquet to keep the two stacks in sync. Fixes GHSA-qrv3-253h-g69c.
1 parent 6e218db commit bee4bf4

20 files changed

Lines changed: 703 additions & 47 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/installing.env-installer": patch
3+
"pnpm": patch
4+
---
5+
6+
Security: validate config dependency names and versions from the env lockfile (`pnpm-lock.yaml`) before using them to build filesystem paths. A committed lockfile with a traversal-shaped `configDependencies` name (such as `../../PWNED`) or version (such as `../../../PWNED`) could previously cause `pnpm install` to create symlinks or write package files outside `node_modules/.pnpm-config` and the store. Names must now be valid npm package names and versions must be exact semver versions; the same validation is applied to optional subdependencies of config dependencies, and to the legacy workspace-manifest format before any lockfile is written. See [GHSA-qrv3-253h-g69c](https://github.com/pnpm/pnpm/security/advisories/GHSA-qrv3-253h-g69c).

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { PnpmError } from '@pnpm/error'
2+
import semver from 'semver'
3+
4+
// A config-dep version becomes a store path segment (`<name>/<version>/<hash>`),
5+
// so reject non-semver values to keep a traversal-shaped version from escaping
6+
// the store root.
7+
export function assertValidConfigDepVersion (name: string, version: string): void {
8+
if (semver.valid(version) == null) {
9+
throw new PnpmError(
10+
'INVALID_CONFIG_DEP_VERSION',
11+
`The config dependency "${name}" has an invalid version "${version}"`,
12+
{ hint: 'A config dependency version must be an exact semver version.' }
13+
)
14+
}
15+
}

installing/env-installer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDe
22
export { resolveAndInstallConfigDeps, type ResolveAndInstallConfigDepsOpts } from './resolveAndInstallConfigDeps.js'
33
export { resolveConfigDeps, type ResolveConfigDepsOpts } from './resolveConfigDeps.js'
44
export { isPackageManagerResolved, resolvePackageManagerIntegrities, type ResolvePackageManagerIntegritiesOpts } from './resolvePackageManagerIntegrities.js'
5+
export { verifyEnvLockfile } from './verifyEnvLockfile.js'

installing/env-installer/src/installConfigDeps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { symlinkDir } from 'symlink-dir'
1616

1717
import { migrateConfigDepsToLockfile } from './migrateConfigDeps.js'
1818
import type { NormalizedConfigDep, NormalizedSubdep } from './parseIntegrity.js'
19+
import { verifyEnvLockfile } from './verifyEnvLockfile.js'
1920

2021
export interface InstallConfigDepsOpts {
2122
frozenLockfile?: boolean
@@ -133,13 +134,15 @@ async function normalizeForInstall (
133134
): Promise<Record<string, NormalizedConfigDep>> {
134135
// If it's a EnvLockfile object (has lockfileVersion), use it directly
135136
if (isEnvLockfile(configDepsOrLockfile)) {
137+
verifyEnvLockfile(configDepsOrLockfile)
136138
return normalizeFromLockfile(configDepsOrLockfile, opts.registries)
137139
}
138140

139141
// It's ConfigDependencies from workspace manifest.
140142
// Try to read the env lockfile first.
141143
const envLockfile = await readEnvLockfile(opts.rootDir)
142144
if (envLockfile) {
145+
verifyEnvLockfile(envLockfile)
143146
return normalizeFromLockfile(envLockfile, opts.registries)
144147
}
145148

installing/env-installer/src/migrateConfigDeps.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
22
import { writeSettings } from '@pnpm/config.writer'
33
import { PnpmError } from '@pnpm/error'
4-
import { createEnvLockfile, writeEnvLockfile } from '@pnpm/lockfile.fs'
4+
import { createEnvLockfile } from '@pnpm/lockfile.fs'
55
import { toLockfileResolution } from '@pnpm/lockfile.utils'
66
import type { ConfigDependencies, ConfigDependencySpecifiers, Registries } from '@pnpm/types'
77
import getNpmTarballUrl from 'get-npm-tarball-url'
88

99
import type { NormalizedConfigDep } from './parseIntegrity.js'
1010
import { parseIntegrity } from './parseIntegrity.js'
11+
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
1112

1213
interface MigrateOpts {
1314
registries: Registries
@@ -26,6 +27,9 @@ export async function migrateConfigDepsToLockfile (
2627
opts: MigrateOpts
2728
): Promise<Record<string, NormalizedConfigDep>> {
2829
const envLockfile = createEnvLockfile()
30+
// Null-prototype so a `__proto__` name lands as an own key verifyEnvLockfile
31+
// sees, not a silent prototype mutation.
32+
envLockfile.importers['.'].configDependencies = Object.create(null)
2933
const cleanSpecifiers: ConfigDependencySpecifiers = {}
3034
const normalizedDeps: Record<string, NormalizedConfigDep> = {}
3135

@@ -89,17 +93,14 @@ export async function migrateConfigDepsToLockfile (
8993
}
9094
}
9195

92-
// Write the new env lockfile and clean up workspace manifest
93-
await Promise.all([
94-
writeEnvLockfile(opts.rootDir, envLockfile),
95-
writeSettings({
96-
rootProjectManifestDir: opts.rootDir,
97-
workspaceDir: opts.rootDir,
98-
updatedSettings: {
99-
configDependencies: cleanSpecifiers,
100-
},
101-
}),
102-
])
96+
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
97+
await writeSettings({
98+
rootProjectManifestDir: opts.rootDir,
99+
workspaceDir: opts.rootDir,
100+
updatedSettings: {
101+
configDependencies: cleanSpecifiers,
102+
},
103+
})
103104

104105
return normalizedDeps
105106
}

installing/env-installer/src/resolveAndInstallConfigDeps.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
createEnvLockfile,
55
type EnvLockfile,
66
readEnvLockfile,
7-
writeEnvLockfile,
87
} from '@pnpm/lockfile.fs'
98
import { toLockfileResolution } from '@pnpm/lockfile.utils'
109
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
@@ -17,6 +16,7 @@ import { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDe
1716
import { parseIntegrity } from './parseIntegrity.js'
1817
import { pruneEnvLockfile } from './pruneEnvLockfile.js'
1918
import { resolveOptionalSubdeps } from './resolveOptionalSubdeps.js'
19+
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
2020

2121
export type ResolveAndInstallConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFactoryOptions & InstallConfigDepsOpts & {
2222
rootDir: string
@@ -92,7 +92,7 @@ export async function resolveAndInstallConfigDeps (
9292

9393
if (depsToResolve.length === 0) {
9494
if (lockfileChanged) {
95-
await writeEnvLockfile(opts.rootDir, envLockfile)
95+
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
9696
}
9797
await installConfigDeps(envLockfile, opts)
9898
return
@@ -143,6 +143,6 @@ export async function resolveAndInstallConfigDeps (
143143

144144
pruneEnvLockfile(envLockfile)
145145

146-
await writeEnvLockfile(opts.rootDir, envLockfile)
146+
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
147147
await installConfigDeps(envLockfile, opts)
148148
}

installing/env-installer/src/resolveConfigDeps.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
createEnvLockfile,
66
type EnvLockfile,
77
readEnvLockfile,
8-
writeEnvLockfile,
98
} from '@pnpm/lockfile.fs'
109
import { toLockfileResolution } from '@pnpm/lockfile.utils'
1110
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
@@ -17,6 +16,7 @@ import type { ConfigDependencies, ConfigDependencySpecifiers, RegistryConfig } f
1716
import { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDeps.js'
1817
import { pruneEnvLockfile } from './pruneEnvLockfile.js'
1918
import { resolveOptionalSubdeps } from './resolveOptionalSubdeps.js'
19+
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
2020

2121
export type ResolveConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFactoryOptions & InstallConfigDepsOpts & {
2222
configDependencies?: ConfigDependencies
@@ -81,17 +81,15 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf
8181

8282
pruneEnvLockfile(envLockfile)
8383

84-
await Promise.all([
85-
writeSettings({
86-
...opts,
87-
rootProjectManifestDir: opts.rootDir,
88-
workspaceDir: opts.rootDir,
89-
updatedSettings: {
90-
configDependencies: configDependencySpecifiers,
91-
},
92-
}),
93-
writeEnvLockfile(opts.rootDir, envLockfile),
94-
])
84+
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
85+
await writeSettings({
86+
...opts,
87+
rootProjectManifestDir: opts.rootDir,
88+
workspaceDir: opts.rootDir,
89+
updatedSettings: {
90+
configDependencies: configDependencySpecifiers,
91+
},
92+
})
9593
await installConfigDeps(envLockfile, opts)
9694
}
9795

installing/env-installer/src/resolvePackageManagerIntegrities.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { convertToLockfileFile, createEnvLockfile, readEnvLockfile, writeEnvLockfile } from '@pnpm/lockfile.fs'
1+
import { convertToLockfileFile, createEnvLockfile, readEnvLockfile } from '@pnpm/lockfile.fs'
22
import { pruneSharedLockfile } from '@pnpm/lockfile.pruner'
33
import type { EnvLockfile } from '@pnpm/lockfile.types'
44
import type { StoreController } from '@pnpm/store.controller'
55
import type { DepPath, ProjectId, Registries } from '@pnpm/types'
66

77
import { convertToLockfileEnvObject } from './pruneEnvLockfile.js'
88
import { resolveManifestDependencies } from './resolveManifestDependencies.js'
9+
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
910

1011
export interface ResolvePackageManagerIntegritiesOpts {
1112
envLockfile?: EnvLockfile
@@ -93,7 +94,7 @@ export async function resolvePackageManagerIntegrities (
9394
envLockfile.snapshots = prunedFile.snapshots ?? {}
9495

9596
if (save) {
96-
await writeEnvLockfile(opts.rootDir, envLockfile)
97+
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
9798
}
9899
}
99100
return envLockfile
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { assertValidDependencyAliases } from '@pnpm/installing.deps-resolver'
2+
import type { EnvLockfile } from '@pnpm/lockfile.fs'
3+
4+
import { assertValidConfigDepVersion } from './assertValidConfigDepVersion.js'
5+
6+
// Offline structural gate for the env lockfile, mirroring the alias/shape
7+
// checks `verifyLockfileResolutions` runs over the main lockfile. Config
8+
// dependency and optional-subdependency names and versions become store path
9+
// segments, so reject any that isn't a valid npm name / exact semver before a
10+
// path is built from them.
11+
export function verifyEnvLockfile (envLockfile: EnvLockfile): void {
12+
const configDeps = envLockfile.importers['.']?.configDependencies
13+
assertValidDependencyAliases(configDeps, 'The configDependencies in pnpm-lock.yaml')
14+
if (configDeps == null) return
15+
for (const [name, { version }] of Object.entries(configDeps)) {
16+
assertValidConfigDepVersion(name, version)
17+
const optionalDeps = envLockfile.snapshots[`${name}@${version}`]?.optionalDependencies
18+
if (optionalDeps == null) continue
19+
assertValidDependencyAliases(optionalDeps, `The optionalDependencies of config dependency "${name}" in pnpm-lock.yaml`)
20+
for (const [subdepName, subdepVersion] of Object.entries(optionalDeps)) {
21+
assertValidConfigDepVersion(subdepName, subdepVersion)
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)