Files
pnpm/installing/env-installer/src/migrateConfigDeps.ts
Zoltan Kochan bee4bf41ca 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.
2026-06-17 23:03:38 +00:00

107 lines
3.8 KiB
TypeScript

import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { writeSettings } from '@pnpm/config.writer'
import { PnpmError } from '@pnpm/error'
import { createEnvLockfile } from '@pnpm/lockfile.fs'
import { toLockfileResolution } from '@pnpm/lockfile.utils'
import type { ConfigDependencies, ConfigDependencySpecifiers, Registries } from '@pnpm/types'
import getNpmTarballUrl from 'get-npm-tarball-url'
import type { NormalizedConfigDep } from './parseIntegrity.js'
import { parseIntegrity } from './parseIntegrity.js'
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
interface MigrateOpts {
registries: Registries
rootDir: string
}
/**
* Migrates old-format configDependencies (with inline integrity in pnpm-workspace.yaml)
* to the new pnpm-lock.yaml format.
*
* Returns normalized deps for immediate installation, and writes the env lockfile
* and clean specifiers to pnpm-workspace.yaml as a side effect.
*/
export async function migrateConfigDepsToLockfile (
configDeps: ConfigDependencies,
opts: MigrateOpts
): Promise<Record<string, NormalizedConfigDep>> {
const envLockfile = createEnvLockfile()
// Null-prototype so a `__proto__` name lands as an own key verifyEnvLockfile
// sees, not a silent prototype mutation.
envLockfile.importers['.'].configDependencies = Object.create(null)
const cleanSpecifiers: ConfigDependencySpecifiers = {}
const normalizedDeps: Record<string, NormalizedConfigDep> = {}
for (const [pkgName, pkgSpec] of Object.entries(configDeps)) {
const registry = pickRegistryForPackage(opts.registries, pkgName)
if (typeof pkgSpec === 'object') {
const { version, integrity } = parseIntegrity(pkgName, pkgSpec.integrity)
const tarball = pkgSpec.tarball ?? getNpmTarballUrl(pkgName, version, { registry })
cleanSpecifiers[pkgName] = version
const pkgKey = `${pkgName}@${version}`
envLockfile.importers['.'].configDependencies[pkgName] = {
specifier: version,
version,
}
envLockfile.packages[pkgKey] = {
resolution: toLockfileResolution(
{ name: pkgName, version },
{ integrity, tarball },
registry
),
}
envLockfile.snapshots[pkgKey] = {}
normalizedDeps[pkgName] = {
version,
resolution: { integrity, tarball },
}
continue
}
if (typeof pkgSpec === 'string') {
// This branch only handles the legacy inline format (version+integrity).
// New clean specifiers (just version/range) require an existing pnpm-lock.yaml.
if (!pkgSpec.includes('+')) {
throw new PnpmError(
'CONFIG_DEP_MISSING_LOCKFILE',
`Config dependency "${pkgName}" is already in clean-specifier form (${pkgSpec}) ` +
'but no pnpm-lock.yaml was found to resolve it. ' +
'Please generate and commit pnpm-lock.yaml (for example by running ' +
'`pnpm install` in the workspace root) before attempting to migrate configDependencies.'
)
}
const { version, integrity } = parseIntegrity(pkgName, pkgSpec)
const tarball = getNpmTarballUrl(pkgName, version, { registry })
cleanSpecifiers[pkgName] = version
const pkgKey = `${pkgName}@${version}`
envLockfile.importers['.'].configDependencies[pkgName] = {
specifier: version,
version,
}
envLockfile.packages[pkgKey] = {
resolution: { integrity },
}
envLockfile.snapshots[pkgKey] = {}
normalizedDeps[pkgName] = {
version,
resolution: { integrity, tarball },
}
}
}
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
await writeSettings({
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: cleanSpecifiers,
},
})
return normalizedDeps
}