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.
This commit is contained in:
Zoltan Kochan
2026-06-18 01:03:38 +02:00
committed by GitHub
parent 6e218db8cb
commit bee4bf41ca
20 changed files with 703 additions and 47 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.env-installer": patch
"pnpm": patch
---
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).

1
Cargo.lock generated
View File

@@ -3750,6 +3750,7 @@ dependencies = [
"pacquet-registry",
"pacquet-reporter",
"pacquet-resolving-npm-resolver",
"pacquet-resolving-parse-wanted-dependency",
"pacquet-resolving-resolver-base",
"pacquet-store-dir",
"pacquet-tarball",

View File

@@ -0,0 +1,15 @@
import { PnpmError } from '@pnpm/error'
import semver from 'semver'
// A config-dep version becomes a store path segment (`<name>/<version>/<hash>`),
// so reject non-semver values to keep a traversal-shaped version from escaping
// the store root.
export function assertValidConfigDepVersion (name: string, version: string): void {
if (semver.valid(version) == null) {
throw new PnpmError(
'INVALID_CONFIG_DEP_VERSION',
`The config dependency "${name}" has an invalid version "${version}"`,
{ hint: 'A config dependency version must be an exact semver version.' }
)
}
}

View File

@@ -2,3 +2,4 @@ export { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDe
export { resolveAndInstallConfigDeps, type ResolveAndInstallConfigDepsOpts } from './resolveAndInstallConfigDeps.js'
export { resolveConfigDeps, type ResolveConfigDepsOpts } from './resolveConfigDeps.js'
export { isPackageManagerResolved, resolvePackageManagerIntegrities, type ResolvePackageManagerIntegritiesOpts } from './resolvePackageManagerIntegrities.js'
export { verifyEnvLockfile } from './verifyEnvLockfile.js'

View File

@@ -16,6 +16,7 @@ import { symlinkDir } from 'symlink-dir'
import { migrateConfigDepsToLockfile } from './migrateConfigDeps.js'
import type { NormalizedConfigDep, NormalizedSubdep } from './parseIntegrity.js'
import { verifyEnvLockfile } from './verifyEnvLockfile.js'
export interface InstallConfigDepsOpts {
frozenLockfile?: boolean
@@ -133,6 +134,7 @@ async function normalizeForInstall (
): Promise<Record<string, NormalizedConfigDep>> {
// If it's a EnvLockfile object (has lockfileVersion), use it directly
if (isEnvLockfile(configDepsOrLockfile)) {
verifyEnvLockfile(configDepsOrLockfile)
return normalizeFromLockfile(configDepsOrLockfile, opts.registries)
}
@@ -140,6 +142,7 @@ async function normalizeForInstall (
// Try to read the env lockfile first.
const envLockfile = await readEnvLockfile(opts.rootDir)
if (envLockfile) {
verifyEnvLockfile(envLockfile)
return normalizeFromLockfile(envLockfile, opts.registries)
}

View File

@@ -1,13 +1,14 @@
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { writeSettings } from '@pnpm/config.writer'
import { PnpmError } from '@pnpm/error'
import { createEnvLockfile, writeEnvLockfile } from '@pnpm/lockfile.fs'
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
@@ -26,6 +27,9 @@ export async function migrateConfigDepsToLockfile (
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> = {}
@@ -89,17 +93,14 @@ export async function migrateConfigDepsToLockfile (
}
}
// Write the new env lockfile and clean up workspace manifest
await Promise.all([
writeEnvLockfile(opts.rootDir, envLockfile),
writeSettings({
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: cleanSpecifiers,
},
}),
])
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
await writeSettings({
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: cleanSpecifiers,
},
})
return normalizedDeps
}

View File

@@ -4,7 +4,6 @@ import {
createEnvLockfile,
type EnvLockfile,
readEnvLockfile,
writeEnvLockfile,
} from '@pnpm/lockfile.fs'
import { toLockfileResolution } from '@pnpm/lockfile.utils'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
@@ -17,6 +16,7 @@ import { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDe
import { parseIntegrity } from './parseIntegrity.js'
import { pruneEnvLockfile } from './pruneEnvLockfile.js'
import { resolveOptionalSubdeps } from './resolveOptionalSubdeps.js'
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
export type ResolveAndInstallConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFactoryOptions & InstallConfigDepsOpts & {
rootDir: string
@@ -92,7 +92,7 @@ export async function resolveAndInstallConfigDeps (
if (depsToResolve.length === 0) {
if (lockfileChanged) {
await writeEnvLockfile(opts.rootDir, envLockfile)
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
}
await installConfigDeps(envLockfile, opts)
return
@@ -143,6 +143,6 @@ export async function resolveAndInstallConfigDeps (
pruneEnvLockfile(envLockfile)
await writeEnvLockfile(opts.rootDir, envLockfile)
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
await installConfigDeps(envLockfile, opts)
}

View File

@@ -5,7 +5,6 @@ import {
createEnvLockfile,
type EnvLockfile,
readEnvLockfile,
writeEnvLockfile,
} from '@pnpm/lockfile.fs'
import { toLockfileResolution } from '@pnpm/lockfile.utils'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
@@ -17,6 +16,7 @@ import type { ConfigDependencies, ConfigDependencySpecifiers, RegistryConfig } f
import { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDeps.js'
import { pruneEnvLockfile } from './pruneEnvLockfile.js'
import { resolveOptionalSubdeps } from './resolveOptionalSubdeps.js'
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
export type ResolveConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFactoryOptions & InstallConfigDepsOpts & {
configDependencies?: ConfigDependencies
@@ -81,17 +81,15 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf
pruneEnvLockfile(envLockfile)
await Promise.all([
writeSettings({
...opts,
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: configDependencySpecifiers,
},
}),
writeEnvLockfile(opts.rootDir, envLockfile),
])
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
await writeSettings({
...opts,
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: configDependencySpecifiers,
},
})
await installConfigDeps(envLockfile, opts)
}

View File

@@ -1,4 +1,4 @@
import { convertToLockfileFile, createEnvLockfile, readEnvLockfile, writeEnvLockfile } from '@pnpm/lockfile.fs'
import { convertToLockfileFile, createEnvLockfile, readEnvLockfile } from '@pnpm/lockfile.fs'
import { pruneSharedLockfile } from '@pnpm/lockfile.pruner'
import type { EnvLockfile } from '@pnpm/lockfile.types'
import type { StoreController } from '@pnpm/store.controller'
@@ -6,6 +6,7 @@ import type { DepPath, ProjectId, Registries } from '@pnpm/types'
import { convertToLockfileEnvObject } from './pruneEnvLockfile.js'
import { resolveManifestDependencies } from './resolveManifestDependencies.js'
import { writeVerifiedEnvLockfile } from './writeVerifiedEnvLockfile.js'
export interface ResolvePackageManagerIntegritiesOpts {
envLockfile?: EnvLockfile
@@ -93,7 +94,7 @@ export async function resolvePackageManagerIntegrities (
envLockfile.snapshots = prunedFile.snapshots ?? {}
if (save) {
await writeEnvLockfile(opts.rootDir, envLockfile)
await writeVerifiedEnvLockfile(opts.rootDir, envLockfile)
}
}
return envLockfile

View File

@@ -0,0 +1,24 @@
import { assertValidDependencyAliases } from '@pnpm/installing.deps-resolver'
import type { EnvLockfile } from '@pnpm/lockfile.fs'
import { assertValidConfigDepVersion } from './assertValidConfigDepVersion.js'
// Offline structural gate for the env lockfile, mirroring the alias/shape
// checks `verifyLockfileResolutions` runs over the main lockfile. Config
// dependency and optional-subdependency names and versions become store path
// segments, so reject any that isn't a valid npm name / exact semver before a
// path is built from them.
export function verifyEnvLockfile (envLockfile: EnvLockfile): void {
const configDeps = envLockfile.importers['.']?.configDependencies
assertValidDependencyAliases(configDeps, 'The configDependencies in pnpm-lock.yaml')
if (configDeps == null) return
for (const [name, { version }] of Object.entries(configDeps)) {
assertValidConfigDepVersion(name, version)
const optionalDeps = envLockfile.snapshots[`${name}@${version}`]?.optionalDependencies
if (optionalDeps == null) continue
assertValidDependencyAliases(optionalDeps, `The optionalDependencies of config dependency "${name}" in pnpm-lock.yaml`)
for (const [subdepName, subdepVersion] of Object.entries(optionalDeps)) {
assertValidConfigDepVersion(subdepName, subdepVersion)
}
}
}

View File

@@ -0,0 +1,10 @@
import { type EnvLockfile, writeEnvLockfile } from '@pnpm/lockfile.fs'
import { verifyEnvLockfile } from './verifyEnvLockfile.js'
// Persist an env lockfile only after verifying it, so no code path can write
// one carrying an invalid config-dependency name or version.
export async function writeVerifiedEnvLockfile (rootDir: string, envLockfile: EnvLockfile): Promise<void> {
verifyEnvLockfile(envLockfile)
await writeEnvLockfile(rootDir, envLockfile)
}

View File

@@ -24,6 +24,22 @@ function makeEnvLockfile (deps: Record<string, { version: string, integrity: str
return lockfile
}
// Recursively search `dir` for an entry named `name`, without following
// symlinks (so it can't loop through the links a successful install creates).
function containsEntryNamed (dir: string, name: string): boolean {
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return false
}
for (const entry of entries) {
if (entry.name === name) return true
if (entry.isDirectory() && containsEntryNamed(path.join(dir, entry.name), name)) return true
}
return false
}
test('configuration dependency is installed from env lockfile', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
@@ -83,6 +99,210 @@ test('configuration dependency is installed from env lockfile', async () => {
expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json')).toBeFalsy()
})
test('a config dependency with a path-traversal name in the env lockfile is rejected', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const maliciousName = '../../PWNED'
const lockfile = makeEnvLockfile({
[maliciousName]: { version: '1.0.0', integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') },
})
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid name')
expect(containsEntryNamed(process.cwd(), 'PWNED')).toBe(false)
expect(containsEntryNamed(storeDir, 'PWNED')).toBe(false)
})
test('an optional subdep with a path-traversal name in the env lockfile is rejected', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const parentName = '@pnpm.e2e/foo'
const parentVersion = '100.0.0'
const maliciousSubdepName = '../../PWNED_SUBDEP'
const subdepVersion = '1.0.0'
const lockfile = createEnvLockfile()
const parentKey = `${parentName}@${parentVersion}`
lockfile.importers['.'].configDependencies[parentName] = { specifier: parentVersion, version: parentVersion }
lockfile.packages[parentKey] = { resolution: { integrity: getIntegrity(parentName, parentVersion) } }
lockfile.snapshots[parentKey] = {
optionalDependencies: { [maliciousSubdepName]: subdepVersion },
}
lockfile.packages[`${maliciousSubdepName}@${subdepVersion}`] = {
resolution: { integrity: getIntegrity('@pnpm.e2e/bar', '100.0.0') },
}
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid name')
expect(containsEntryNamed(process.cwd(), 'PWNED_SUBDEP')).toBe(false)
expect(containsEntryNamed(storeDir, 'PWNED_SUBDEP')).toBe(false)
})
test('a config dependency named __proto__ in the env lockfile is rejected', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const lockfile = createEnvLockfile()
// JSON.parse makes `__proto__` an own enumerable key (as on-disk parsing does);
// a plain object literal would set the prototype and hide it.
lockfile.importers['.'].configDependencies = JSON.parse('{"__proto__":{"specifier":"1.0.0","version":"1.0.0"}}')
lockfile.packages['__proto__@1.0.0'] = { resolution: { integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') } }
lockfile.snapshots['__proto__@1.0.0'] = {}
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid name')
expect(containsEntryNamed(process.cwd(), '__proto__')).toBe(false)
})
test('an optional subdep named __proto__ in the env lockfile is rejected', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const parentName = '@pnpm.e2e/foo'
const parentVersion = '100.0.0'
const lockfile = createEnvLockfile()
const parentKey = `${parentName}@${parentVersion}`
lockfile.importers['.'].configDependencies[parentName] = { specifier: parentVersion, version: parentVersion }
lockfile.packages[parentKey] = { resolution: { integrity: getIntegrity(parentName, parentVersion) } }
// JSON.parse so `__proto__` is an own enumerable key.
lockfile.snapshots[parentKey] = { optionalDependencies: JSON.parse('{"__proto__":"1.0.0"}') }
lockfile.packages['__proto__@1.0.0'] = { resolution: { integrity: getIntegrity('@pnpm.e2e/bar', '100.0.0') } }
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid name')
expect(containsEntryNamed(process.cwd(), '__proto__')).toBe(false)
expect(containsEntryNamed(storeDir, '__proto__')).toBe(false)
})
test('an invalid config dependency name in the workspace manifest is rejected before any lockfile is written', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0')
const configDeps: Record<string, string> = {
'../../PWNED': `100.0.0+${integrity}`,
}
await expect(installConfigDeps(configDeps, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid name')
expect(fs.existsSync('pnpm-lock.yaml')).toBe(false)
expect(containsEntryNamed(process.cwd(), 'PWNED')).toBe(false)
})
test('an invalid config dependency version in the workspace manifest is rejected before any lockfile is written', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0')
const configDeps: Record<string, string> = {
'@pnpm.e2e/foo': `../../../PWNED+${integrity}`,
}
await expect(installConfigDeps(configDeps, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid version')
expect(fs.existsSync('pnpm-lock.yaml')).toBe(false)
expect(containsEntryNamed(process.cwd(), 'PWNED')).toBe(false)
})
test('a config dependency with a path-traversal version in the env lockfile is rejected', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const maliciousVersion = '../../../PWNED'
const lockfile = makeEnvLockfile({
'@pnpm.e2e/foo': { version: maliciousVersion, integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') },
})
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid version')
expect(containsEntryNamed(process.cwd(), 'PWNED')).toBe(false)
expect(containsEntryNamed(storeDir, 'PWNED')).toBe(false)
})
test('an optional subdep with a path-traversal version in the env lockfile is rejected', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
const parentName = '@pnpm.e2e/foo'
const parentVersion = '100.0.0'
const subdepName = '@pnpm.e2e/bar'
const maliciousVersion = '../../../PWNED'
const lockfile = createEnvLockfile()
const parentKey = `${parentName}@${parentVersion}`
lockfile.importers['.'].configDependencies[parentName] = { specifier: parentVersion, version: parentVersion }
lockfile.packages[parentKey] = { resolution: { integrity: getIntegrity(parentName, parentVersion) } }
lockfile.snapshots[parentKey] = {
optionalDependencies: { [subdepName]: maliciousVersion },
}
lockfile.packages[`${subdepName}@${maliciousVersion}`] = {
resolution: { integrity: getIntegrity(subdepName, '100.0.0') },
}
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('invalid version')
expect(containsEntryNamed(process.cwd(), 'PWNED')).toBe(false)
expect(containsEntryNamed(storeDir, 'PWNED')).toBe(false)
})
test('optional subdep matching the current platform is installed and symlinked next to parent', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()

View File

@@ -11,19 +11,20 @@ license.workspace = true
repository.workspace = true
[dependencies]
pacquet-config = { workspace = true }
pacquet-diagnostics = { workspace = true }
pacquet-fs = { workspace = true }
pacquet-graph-hasher = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-network = { workspace = true }
pacquet-package-is-installable = { workspace = true }
pacquet-package-manager = { workspace = true }
pacquet-reporter = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
pacquet-store-dir = { workspace = true }
pacquet-tarball = { workspace = true }
pacquet-workspace-state = { workspace = true }
pacquet-config = { workspace = true }
pacquet-diagnostics = { workspace = true }
pacquet-fs = { workspace = true }
pacquet-graph-hasher = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-network = { workspace = true }
pacquet-package-is-installable = { workspace = true }
pacquet-package-manager = { workspace = true }
pacquet-reporter = { workspace = true }
pacquet-resolving-parse-wanted-dependency = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
pacquet-store-dir = { workspace = true }
pacquet-tarball = { workspace = true }
pacquet-workspace-state = { workspace = true }
derive_more = { workspace = true }
futures-util = { workspace = true }

View File

@@ -41,6 +41,29 @@ pub enum ConfigDepError {
#[diagnostic(code(ERR_PNPM_ENV_LOCKFILE_CORRUPTED))]
EnvLockfileCorrupted { message: String },
/// Mirrors pnpm's `INVALID_DEPENDENCY_NAME`, thrown by
/// [`assertValidDependencyAliases`](https://github.com/pnpm/pnpm/blob/main/installing/deps-resolver/src/validateDependencyAlias.ts).
#[display(r"{description} contains a dependency with an invalid name: {name:?}")]
#[diagnostic(
code(ERR_PNPM_INVALID_DEPENDENCY_NAME),
help(
"A dependency name must be a valid npm package name — a single `name` or `@scope/name` \
consisting of URL-friendly characters, with no leading `.` or `_`, and not equal to \
reserved names such as `node_modules`."
)
)]
InvalidDependencyName { description: String, name: String },
/// A config-dependency version is a store path segment
/// (`<name>/<version>/<hash>`), so a non-semver value is rejected to keep a
/// traversal-shaped version from escaping the store root.
#[display(r#"The config dependency "{name}" has an invalid version "{version}""#)]
#[diagnostic(
code(ERR_PNPM_INVALID_CONFIG_DEP_VERSION),
help("A config dependency version must be an exact semver version.")
)]
InvalidConfigDepVersion { name: String, version: String },
#[display("Failed to resolve config dependency {spec}: {error}")]
#[diagnostic(code(ERR_PNPM_BAD_CONFIG_DEP))]
Resolve {

View File

@@ -10,6 +10,7 @@
use crate::{
ConfigDepError, NormalizedConfigDep, NormalizedSubdep, options::ConfigDepsInstallOptions,
verify_env_lockfile::verify_env_lockfile,
};
use pacquet_graph_hasher::{
calc_global_virtual_store_path_with_subdeps, calc_leaf_global_virtual_store_path,
@@ -39,6 +40,7 @@ pub async fn install_config_deps<Reporter: self::Reporter>(
env_lockfile: &EnvLockfile,
opts: &ConfigDepsInstallOptions<'_>,
) -> Result<(), ConfigDepError> {
verify_env_lockfile(env_lockfile)?;
let normalized = normalize_from_lockfile(env_lockfile, opts)?;
let global_virtual_store_dir = opts.store_dir.links();
let config_modules_dir = opts.root_dir.join("node_modules").join(".pnpm-config");

View File

@@ -23,6 +23,7 @@ mod prune;
mod resolve_and_install_config_deps;
mod resolve_optional_subdeps;
mod resolve_package_manager_integrities;
mod verify_env_lockfile;
pub use errors::ConfigDepError;
pub use install_config_deps::install_config_deps;
@@ -34,6 +35,7 @@ pub use resolve_optional_subdeps::resolve_optional_subdeps;
pub use resolve_package_manager_integrities::{
is_package_manager_resolved, resolve_package_manager_integrities,
};
pub use verify_env_lockfile::{verify_env_lockfile, write_verified_env_lockfile};
#[cfg(test)]
mod tests;

View File

@@ -13,6 +13,7 @@ use crate::{
ConfigDepError, install_config_deps::install_config_deps, options::ConfigDepsInstallOptions,
parse_integrity::parse_integrity, prune::prune_env_lockfile,
resolve_optional_subdeps::resolve_optional_subdeps,
verify_env_lockfile::write_verified_env_lockfile,
};
use pacquet_lockfile::{
EnvLockfile, LockfileResolution, PackageKey, PackageMetadata, SnapshotEntry,
@@ -109,7 +110,7 @@ pub async fn resolve_and_install_config_deps<Reporter: self::Reporter>(
// Migration and/or removal changed the lockfile; prune any
// now-orphaned packages/snapshots before writing.
prune_env_lockfile(&mut env_lockfile);
env_lockfile.write(opts.root_dir).map_err(ConfigDepError::WriteLockfile)?;
write_verified_env_lockfile(&env_lockfile, opts.root_dir)?;
}
return install_config_deps::<Reporter>(&env_lockfile, opts).await;
}
@@ -119,7 +120,7 @@ pub async fn resolve_and_install_config_deps<Reporter: self::Reporter>(
}
prune_env_lockfile(&mut env_lockfile);
env_lockfile.write(opts.root_dir).map_err(ConfigDepError::WriteLockfile)?;
write_verified_env_lockfile(&env_lockfile, opts.root_dir)?;
install_config_deps::<Reporter>(&env_lockfile, opts).await
}

View File

@@ -4,6 +4,7 @@ use crate::{
options::ConfigDepsInstallOptions,
prune::prune_env_lockfile,
resolve_optional_subdeps::resolution_has_integrity,
verify_env_lockfile::write_verified_env_lockfile,
};
use pacquet_lockfile::{
EnvLockfile, PackageKey, PkgName, PkgVerPeer, SnapshotDepRef, SnapshotEntry,
@@ -93,7 +94,7 @@ pub async fn resolve_package_manager_integrities(
}
prune_env_lockfile(&mut env_lockfile);
env_lockfile.write(opts.root_dir).map_err(ConfigDepError::WriteLockfile)
write_verified_env_lockfile(&env_lockfile, opts.root_dir)
}
#[must_use]

View File

@@ -1,6 +1,6 @@
use crate::{
ConfigDepError, ConfigDepsInstallOptions, is_package_manager_resolved, prune_env_lockfile,
resolve_and_install_config_deps, resolve_package_manager_integrities,
ConfigDepError, ConfigDepsInstallOptions, install_config_deps, is_package_manager_resolved,
prune_env_lockfile, resolve_and_install_config_deps, resolve_package_manager_integrities,
};
use pacquet_lockfile::{
EnvLockfile, LockfileResolution, PackageKey, PackageMetadata, RegistryResolution,
@@ -369,6 +369,277 @@ async fn rejects_optional_subdep_with_non_exact_version() {
);
}
/// Recursively search `dir` for an entry named `name`, without following
/// symlinks (so it can't loop through the dir links a successful install leaves).
fn contains_entry_named(dir: &Path, name: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
if entry.file_name() == name {
return true;
}
if entry.file_type().is_ok_and(|file_type| file_type.is_dir())
&& contains_entry_named(&entry.path(), name)
{
return true;
}
}
false
}
#[tokio::test]
async fn rejects_config_dep_with_path_traversal_name() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
// Resolve a legit config dep, then re-key its entry under a traversal-shaped
// name to mimic a malicious committed lockfile.
let mut config_deps = BTreeMap::new();
config_deps.insert("@pnpm.e2e/foo".to_string(), clean_spec("100.0.0"));
resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.unwrap();
let mut env = EnvLockfile::read(root.path()).unwrap().expect("env lockfile written");
let spec = env.root_importer_mut().config_dependencies.remove("@pnpm.e2e/foo").unwrap();
let malicious_name = "../../PWNED_CFGDEP".to_string();
env.root_importer_mut().config_dependencies.insert(malicious_name.clone(), spec.clone());
let legit_key: PackageKey = "@pnpm.e2e/foo@100.0.0".parse().unwrap();
let pkg = env.packages[&legit_key].clone();
let malicious_key: PackageKey = format!("{malicious_name}@{}", spec.version).parse().unwrap();
env.packages.insert(malicious_key, pkg);
let error = install_config_deps::<SilentReporter>(&env, &options(&harness, root.path(), false))
.await
.expect_err("a traversal-shaped config dep name must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidDependencyName { .. }),
"unexpected error: {error:?}",
);
assert!(!contains_entry_named(root.path(), "PWNED_CFGDEP"));
assert!(!contains_entry_named(&harness.store_dir.links(), "PWNED_CFGDEP"));
}
#[tokio::test]
async fn rejects_optional_subdep_with_path_traversal_name() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
let mut config_deps = BTreeMap::new();
config_deps.insert("@pnpm.e2e/foo".to_string(), clean_spec("100.0.0"));
resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.unwrap();
let mut env = EnvLockfile::read(root.path()).unwrap().expect("env lockfile written");
let parent_key: PackageKey = "@pnpm.e2e/foo@100.0.0".parse().unwrap();
let pkg = env.packages[&parent_key].clone();
let malicious_name = "../../PWNED_SUBDEP".to_string();
let malicious_key: PackageKey = format!("{malicious_name}@100.0.0").parse().unwrap();
env.packages.insert(malicious_key, pkg);
let subdep_name: pacquet_lockfile::PkgName = malicious_name.parse().unwrap();
let subdep_ref: SnapshotDepRef = "100.0.0".parse().unwrap();
env.snapshots.entry(parent_key).or_default().optional_dependencies =
Some(std::iter::once((subdep_name, subdep_ref)).collect());
let error = install_config_deps::<SilentReporter>(&env, &options(&harness, root.path(), false))
.await
.expect_err("a traversal-shaped optional subdep name must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidDependencyName { .. }),
"unexpected error: {error:?}",
);
assert!(!contains_entry_named(root.path(), "PWNED_SUBDEP"));
assert!(!contains_entry_named(&harness.store_dir.links(), "PWNED_SUBDEP"));
}
/// `__proto__` is an invalid npm name (leading `_`); Rust's string-keyed maps
/// reject it with none of the null-prototype handling the JS side needs.
#[tokio::test]
async fn rejects_config_dep_named_dunder_proto() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
let mut config_deps = BTreeMap::new();
config_deps.insert("@pnpm.e2e/foo".to_string(), clean_spec("100.0.0"));
resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.unwrap();
let mut env = EnvLockfile::read(root.path()).unwrap().expect("env lockfile written");
let spec = env.root_importer_mut().config_dependencies.remove("@pnpm.e2e/foo").unwrap();
let malicious_name = "__proto__".to_string();
env.root_importer_mut().config_dependencies.insert(malicious_name.clone(), spec.clone());
let legit_key: PackageKey = "@pnpm.e2e/foo@100.0.0".parse().unwrap();
let pkg = env.packages[&legit_key].clone();
let malicious_key: PackageKey = format!("{malicious_name}@{}", spec.version).parse().unwrap();
env.packages.insert(malicious_key, pkg);
let error = install_config_deps::<SilentReporter>(&env, &options(&harness, root.path(), false))
.await
.expect_err("a config dep named __proto__ must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidDependencyName { .. }),
"unexpected error: {error:?}",
);
}
#[tokio::test]
async fn rejects_invalid_manifest_config_dep_name_before_writing_lockfile() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
let mut config_deps = BTreeMap::new();
config_deps.insert(
"../../PWNED".to_string(),
ConfigDependency::VersionWithIntegrity("100.0.0+sha512-deadbeef".to_string()),
);
let error = resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.expect_err("an invalid manifest config dep name must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidDependencyName { .. }),
"unexpected error: {error:?}",
);
assert!(!root.path().join("pnpm-lock.yaml").exists());
}
#[tokio::test]
async fn rejects_invalid_manifest_config_dep_version_before_writing_lockfile() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
let integrity = integrity_of(&resolver, "@pnpm.e2e/foo", "100.0.0").await;
let mut config_deps = BTreeMap::new();
config_deps.insert(
"@pnpm.e2e/foo".to_string(),
ConfigDependency::VersionWithIntegrity(format!("../../../PWNED+{integrity}")),
);
let error = resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.expect_err("an invalid manifest config dep version must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidConfigDepVersion { .. }),
"unexpected error: {error:?}",
);
assert!(!root.path().join("pnpm-lock.yaml").exists());
}
#[tokio::test]
async fn rejects_config_dep_with_path_traversal_version() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
let mut config_deps = BTreeMap::new();
config_deps.insert("@pnpm.e2e/foo".to_string(), clean_spec("100.0.0"));
resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.unwrap();
let mut env = EnvLockfile::read(root.path()).unwrap().expect("env lockfile written");
let malicious_version = "../../../PWNED";
env.root_importer_mut().config_dependencies.get_mut("@pnpm.e2e/foo").unwrap().version =
malicious_version.to_string();
let legit_key: PackageKey = "@pnpm.e2e/foo@100.0.0".parse().unwrap();
let pkg = env.packages[&legit_key].clone();
let malicious_key: PackageKey = format!("@pnpm.e2e/foo@{malicious_version}").parse().unwrap();
env.packages.insert(malicious_key, pkg);
let error = install_config_deps::<SilentReporter>(&env, &options(&harness, root.path(), false))
.await
.expect_err("a traversal-shaped config dep version must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidConfigDepVersion { .. }),
"unexpected error: {error:?}",
);
// Pin the message format (guards against a doubled/dropped quote).
let message = error.to_string();
assert_eq!(
message,
r#"The config dependency "@pnpm.e2e/foo" has an invalid version "../../../PWNED""#,
);
assert!(!contains_entry_named(root.path(), "PWNED"));
assert!(!contains_entry_named(&harness.store_dir.links(), "PWNED"));
}
#[tokio::test]
async fn rejects_optional_subdep_with_path_traversal_version() {
let harness = harness();
let (resolver, _cache) = build_resolver(&harness.registry_url);
let root = TempDir::new().unwrap();
let mut config_deps = BTreeMap::new();
config_deps.insert("@pnpm.e2e/foo".to_string(), clean_spec("100.0.0"));
resolve_and_install_config_deps::<SilentReporter>(
&config_deps,
&resolver,
&options(&harness, root.path(), false),
)
.await
.unwrap();
let mut env = EnvLockfile::read(root.path()).unwrap().expect("env lockfile written");
let parent_key: PackageKey = "@pnpm.e2e/foo@100.0.0".parse().unwrap();
let pkg = env.packages[&parent_key].clone();
let malicious_version = "../../../PWNED";
let subdep_name = "@pnpm.e2e/bar";
let malicious_key: PackageKey = format!("{subdep_name}@{malicious_version}").parse().unwrap();
env.packages.insert(malicious_key, pkg);
let subdep_name_parsed: pacquet_lockfile::PkgName = subdep_name.parse().unwrap();
let subdep_ref: SnapshotDepRef = malicious_version.parse().unwrap();
env.snapshots.entry(parent_key).or_default().optional_dependencies =
Some(std::iter::once((subdep_name_parsed, subdep_ref)).collect());
let error = install_config_deps::<SilentReporter>(&env, &options(&harness, root.path(), false))
.await
.expect_err("a traversal-shaped optional subdep version must be rejected");
assert!(
matches!(error, ConfigDepError::InvalidConfigDepVersion { .. }),
"unexpected error: {error:?}",
);
assert!(!contains_entry_named(root.path(), "PWNED"));
assert!(!contains_entry_named(&harness.store_dir.links(), "PWNED"));
}
#[tokio::test]
async fn frozen_lockfile_rejects_new_config_dep() {
let harness = harness();

View File

@@ -0,0 +1,75 @@
//! Offline structural gate for the env lockfile, mirroring pnpm's
//! [`verifyEnvLockfile`](https://github.com/pnpm/pnpm/blob/main/installing/env-installer/src/verifyEnvLockfile.ts)
//! and the always-on alias/shape checks `verifyLockfileResolutions` runs over
//! the main lockfile.
use crate::ConfigDepError;
use pacquet_lockfile::{EnvLockfile, PackageKey};
use pacquet_resolving_parse_wanted_dependency::is_valid_old_npm_package_name;
use std::path::Path;
/// Persist an env lockfile only after verifying it, so no code path can write
/// one carrying an invalid config-dependency name or version.
pub fn write_verified_env_lockfile(
env_lockfile: &EnvLockfile,
root_dir: &Path,
) -> Result<(), ConfigDepError> {
verify_env_lockfile(env_lockfile)?;
env_lockfile.write(root_dir).map_err(ConfigDepError::WriteLockfile)
}
/// Reject config-dependency and optional-subdep names/versions before they
/// build store paths (`<name>/<version>/<hash>`): names must be valid npm
/// package names, versions exact semver — otherwise a traversal-shaped value
/// would escape the install roots.
pub fn verify_env_lockfile(env_lockfile: &EnvLockfile) -> Result<(), ConfigDepError> {
let Some(importer) = env_lockfile.importers.get(EnvLockfile::ROOT_IMPORTER_KEY) else {
return Ok(());
};
for (name, spec) in &importer.config_dependencies {
assert_valid_name(name, "The configDependencies in pnpm-lock.yaml")?;
assert_valid_version(name, &spec.version)?;
let Ok(key) = format!("{name}@{}", spec.version).parse::<PackageKey>() else {
continue;
};
let Some(optionals) = env_lockfile
.snapshots
.get(&key)
.and_then(|snapshot| snapshot.optional_dependencies.as_ref())
else {
continue;
};
let description =
format!("The optionalDependencies of config dependency \"{name}\" in pnpm-lock.yaml");
for (subdep_name, dep_ref) in optionals {
let subdep_name = subdep_name.to_string();
assert_valid_name(&subdep_name, &description)?;
let version = dep_ref.ver_peer().map(ToString::to_string).unwrap_or_default();
assert_valid_version(&subdep_name, &version)?;
}
}
Ok(())
}
fn assert_valid_name(name: &str, description: &str) -> Result<(), ConfigDepError> {
if is_valid_old_npm_package_name(name) {
Ok(())
} else {
Err(ConfigDepError::InvalidDependencyName {
description: description.to_string(),
name: name.to_string(),
})
}
}
fn assert_valid_version(name: &str, version: &str) -> Result<(), ConfigDepError> {
if version.parse::<node_semver::Version>().is_err() {
Err(ConfigDepError::InvalidConfigDepVersion {
name: name.to_string(),
version: version.to_string(),
})
} else {
Ok(())
}
}