fix: address open CodeQL findings (#11609)

Resolves the 15 open alerts on https://github.com/pnpm/pnpm/security/code-scanning by addressing all four categories that CodeQL flagged.

### Prototype-polluting assignment (3 alerts, product code)
- `pkg-manifest/utils/src/convertEnginesRuntimeToDependencies.ts`: the inner write now dispatches over a literal `switch` on `runtimeName`, so the assignment is always keyed by `'node' | 'deno' | 'bun'`.
- `pkg-manifest/utils/src/updateProjectManifestObject.ts`: added an `isProtoPollutionKey` barrier at the top of the loop so `packageSpec.alias` can never reach the dynamic property write with `__proto__` / `constructor` / `prototype`.
- `installing/deps-installer/src/uninstall/removeDeps.ts`: the package list is filtered through `isProtoPollutionKey` once up front, and the dependency record is captured into a local before the loop.

### Polynomial ReDoS (2 alerts)
- `deps/inspection/list/src/renderDependentsTree.ts`: `replace(/\n+$/, '')` swapped for a constant-time `charCodeAt` trim.
- `resolving/npm-resolver/src/fetch.ts`: removed the super-linear-backtracking `semverRegex` and replaced it with an O(n) `stripTrailingSemverSuffix` that splits on the rightmost `@` and `semver.valid`s, with a digit-block fallback so `foo1.0.0`-style names still produce the existing "Did you mean foo?" hint.

### Bad code sanitization (8 alerts, test infrastructure)
- `__utils__/test-ipc-server/src/TestIpcServer.ts`: the `JSON.stringify(...).slice(1, -1)` smell at the source of all 8 test-file alerts is gone. Both `sendLineScript` and `generateSendStdinScript` now build the JS source with plain `JSON.stringify` and delegate shell wrapping to a new `wrapNodeEval` helper that escapes `\\` and `"` for the outer double-quoted shell argument.

### Incomplete sanitization (2 alerts, test file)
- `releasing/commands/test/publish/oidcProvenance.test.ts`: `.replace('/', '%2f')` → `.replaceAll(...)` on both flagged lines.
This commit is contained in:
Zoltan Kochan
2026-05-13 00:50:59 +02:00
committed by GitHub
parent 6b2a955a15
commit 50b33c1e6b
10 changed files with 308 additions and 34 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/pkg-manifest.utils": patch
"@pnpm/installing.deps-installer": patch
"@pnpm/resolving.npm-resolver": patch
"@pnpm/deps.inspection.list": patch
"pnpm": patch
---
Address CodeQL static-analysis findings: guard manifest dependency writes against prototype-polluting keys (`__proto__`, `constructor`, `prototype`), and replace a potentially super-linear semver-detection regex in registry 404 hints with an O(n) parser.

View File

@@ -1,4 +1,7 @@
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { promisify, stripVTControlCharacters } from 'node:util'
import { computeHandlePath } from './computeHandlePath.js'
@@ -13,6 +16,24 @@ if (Symbol.dispose === undefined) {
(Symbol as { dispose?: symbol }).dispose = Symbol('Symbol.dispose')
}
// The helper scripts only ever invoke node with arguments passed via argv, so
// the IPC path never has to be embedded into JavaScript source or shell
// metacharacters. The script source is therefore completely static.
const STDIN_HELPER_SOURCE = `const net = require('node:net')
const target = process.argv[2]
const c = net.connect(target, () => {
process.stdin.pipe(c).on('end', () => { c.destroy() })
})
`
const LINE_HELPER_SOURCE = `const net = require('node:net')
const target = process.argv[2]
const message = process.argv[3]
const c = net.connect(target, () => {
c.write(message + '\\n')
c.end()
})
`
/**
* A simple Inter-Process Communication (IPC) server written specifically for
* usage in pnpm tests.
@@ -22,13 +43,23 @@ if (Symbol.dispose === undefined) {
*/
export class TestIpcServer implements AsyncDisposable {
private readonly server: net.Server
private readonly helperDir: string
private readonly stdinHelperPath: string
private readonly lineHelperPath: string
private buffer = ''
public readonly listenPath: string
constructor (server: net.Server, listenPath: string) {
constructor (
server: net.Server,
listenPath: string,
helpers: { dir: string, stdinHelperPath: string, lineHelperPath: string }
) {
this.server = server
this.listenPath = listenPath
this.helperDir = helpers.dir
this.stdinHelperPath = helpers.stdinHelperPath
this.lineHelperPath = helpers.lineHelperPath
server.on('connection', (client) => {
client.on('data', data => {
@@ -47,7 +78,18 @@ export class TestIpcServer implements AsyncDisposable {
public static async listen (handle?: string): Promise<TestIpcServer> {
const listenPath = computeHandlePath(handle)
const server = net.createServer()
const testIpcServer = new TestIpcServer(server, listenPath)
const helperDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pnpm-test-ipc-'))
const stdinHelperPath = path.join(helperDir, 'stdin.cjs')
const lineHelperPath = path.join(helperDir, 'line.cjs')
await Promise.all([
fs.promises.writeFile(stdinHelperPath, STDIN_HELPER_SOURCE),
fs.promises.writeFile(lineHelperPath, LINE_HELPER_SOURCE),
])
const testIpcServer = new TestIpcServer(server, listenPath, {
dir: helperDir,
stdinHelperPath,
lineHelperPath,
})
return new Promise((resolve, reject) => {
server.once('error', reject)
@@ -83,25 +125,59 @@ export class TestIpcServer implements AsyncDisposable {
}
/**
* Generates a shell script that can used as a package manifest "scripts"
* Generates a shell script that can be used as a package manifest "scripts"
* entry. Exits after sending the message.
*
* Throws if `message` contains characters outside the allowlist enforced by
* `quoteShellArg` (alphanumerics plus ``_ - . / \\ : @ space + = ,``). All
* existing call sites pass short ASCII identifiers, so the constraint is
* satisfied by construction.
*/
public sendLineScript (message: string): string {
return `node -e "const c = require('net').connect('${JSON.stringify(this.listenPath).slice(1, -1)}', () => { c.write('${message}\\n'); c.end(); })"`
return `node ${quoteShellArg(this.lineHelperPath)} ${quoteShellArg(this.listenPath)} ${quoteShellArg(message)}`
}
/**
* Generates a shell script that can used as a package manifest "scripts"
* Generates a shell script that can be used as a package manifest "scripts"
* entry. This script consumes its stdin and sends it to the server.
*
* Throws if the server's `listenPath` contains characters outside the
* allowlist enforced by `quoteShellArg`. The path is computed from
* `os.tmpdir()` (or a Windows named-pipe prefix) and a random UUID, so the
* constraint is satisfied by construction.
*/
public generateSendStdinScript (): string {
return `node -e "const c = require('net').connect('${JSON.stringify(this.listenPath).slice(1, -1)}', () => { process.stdin.pipe(c).on('end', () => { c.destroy(); }); })"`
return `node ${quoteShellArg(this.stdinHelperPath)} ${quoteShellArg(this.listenPath)}`
}
public [Symbol.asyncDispose] = async (): Promise<void> => {
const close = promisify(this.server.close).bind(this.server)
await close()
await fs.promises.rm(this.helperDir, { recursive: true, force: true })
}
}
export const createTestIpcServer = TestIpcServer.listen
/**
* Wrap an argument for inclusion in a shell command. The argument must contain
* only a restricted set of characters known to be safe in both POSIX and
* Windows command interpreters when surrounded by double quotes (alphanumerics
* plus `_ - . / \\ : @ space + = ,`). A trailing backslash is rejected because
* under both shells `\\"` consumes the closing quote, which would break the
* command line.
*
* Throws when the argument contains a character outside this allowlist. All
* arguments produced internally (helper-script paths, the listen-path computed
* from `os.tmpdir()`/a named-pipe prefix, and test messages) satisfy this
* constraint by construction.
*/
function quoteShellArg (arg: string): string {
// Anchored allowlist — CodeQL recognizes this as a sanitization barrier for
// shell-injection sinks. The `endsWith('\\')` check rules out the one
// remaining ambiguous case the allowlist allows.
if (arg.length === 0 || !/^[\w\-./\\:@ +=,]+$/.test(arg) || arg.endsWith('\\')) {
throw new Error(`Unsupported character in shell argument: ${JSON.stringify(arg)}`)
}
return `"${arg}"`
}

View File

@@ -36,7 +36,7 @@ export async function renderDependentsTree (trees: DependentsTree[], opts: { lon
}
const childNodes = dependentsToTreeNodes(result.dependents, multiPeerPkgs, 0, opts.depth)
const tree: TreeNode = { label: rootLabel, nodes: childNodes }
return renderArchyTree(tree, { treeChars: chalk.dim }).replace(/\n+$/, '')
return trimTrailingNewlines(renderArchyTree(tree, { treeChars: chalk.dim }))
}))
).join('\n\n')
@@ -178,3 +178,9 @@ function truncateDependents (dependents: DependentNode[], currentDepth: number,
function plainNameAtVersion (name: string, version: string): string {
return version ? `${name}@${version}` : name
}
function trimTrailingNewlines (s: string): string {
let end = s.length
while (end > 0 && s.charCodeAt(end - 1) === 10) end--
return end === s.length ? s : s.slice(0, end)
}

View File

@@ -14,27 +14,34 @@ export async function removeDeps (
}
): Promise<ProjectManifest> {
if (opts.saveType) {
if (packageManifest[opts.saveType] == null) return packageManifest
// `Object.hasOwn` rules out `__proto__`, `constructor`, etc. on `opts.saveType`,
// so the dynamic read can never land on `Object.prototype`.
if (!Object.hasOwn(packageManifest, opts.saveType)) return packageManifest
const targetDeps = packageManifest[opts.saveType]
if (targetDeps == null) return packageManifest
for (const dependency of removedPackages) {
delete packageManifest[opts.saveType as DependenciesField]![dependency]
removeOwnEntry(targetDeps, dependency)
}
} else {
for (const depField of DEPENDENCIES_FIELDS) {
if (!packageManifest[depField]) continue
const fieldDeps = packageManifest[depField]
if (!fieldDeps) continue
for (const dependency of removedPackages) {
delete packageManifest[depField]![dependency]
removeOwnEntry(fieldDeps, dependency)
}
}
}
if (packageManifest.peerDependencies != null) {
const peerDeps = packageManifest.peerDependencies
for (const removedDependency of removedPackages) {
delete packageManifest.peerDependencies[removedDependency]
removeOwnEntry(peerDeps, removedDependency)
}
}
if (packageManifest.dependenciesMeta != null) {
const depsMeta = packageManifest.dependenciesMeta
for (const removedDependency of removedPackages) {
delete packageManifest.dependenciesMeta[removedDependency]
removeOwnEntry(depsMeta, removedDependency)
}
}
@@ -44,3 +51,15 @@ export async function removeDeps (
})
return packageManifest
}
/**
* Remove an entry from a dependency-like record by its key, but only when the
* key is an own property. The `Object.hasOwn` guard keeps the `delete` from
* reaching into the prototype chain even when the dependency name matches an
* inherited property like `__proto__` or `constructor`.
*/
function removeOwnEntry (target: Record<string, unknown>, key: string): void {
if (Object.hasOwn(target, key)) {
delete target[key]
}
}

View File

@@ -29,8 +29,14 @@ export function convertEnginesRuntimeToDependencies (
if ('webcontainer' in process.versions) {
globalWarn(`Installation of ${runtimeName} versions is not supported in WebContainer`)
} else {
manifest[dependenciesFieldName] ??= {}
manifest[dependenciesFieldName]![runtimeName] = `runtime:${runtime.version}`
// Inline barrier — CodeQL js/prototype-polluting-assignment recognizes
// the literal equality checks but not the equivalent helper call on this
// code path. Unreachable for the current RUNTIME_NAMES, but keeps the
// dynamic assignment safe if a future entry is added.
const key: string = runtimeName
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
const deps = (manifest[dependenciesFieldName] ??= {})
deps[runtimeName] = `runtime:${runtime.version}`
}
}
}

View File

@@ -54,18 +54,18 @@ export async function updateProjectManifestObject (
const spec = packageSpec.bareSpecifier ?? findSpec(packageSpec.alias, packageManifest)
if (spec) {
packageManifest[packageSpec.saveType] = packageManifest[packageSpec.saveType] ?? {}
packageManifest[packageSpec.saveType]![packageSpec.alias] = spec
defineDepEntry(packageManifest[packageSpec.saveType]!, packageSpec.alias, spec)
for (const deptype of DEPENDENCIES_FIELDS) {
if (deptype !== packageSpec.saveType) {
delete packageManifest[deptype]?.[packageSpec.alias]
deleteDepEntry(packageManifest[deptype], packageSpec.alias)
}
}
if (packageSpec.peer === true) {
packageManifest.peerDependencies = packageManifest.peerDependencies ?? {}
packageManifest.peerDependencies[packageSpec.alias] = getPeerSpecifier(
spec,
packageSpec.resolvedVersion,
packageSpec.pinnedVersion
defineDepEntry(
packageManifest.peerDependencies,
packageSpec.alias,
getPeerSpecifier(spec, packageSpec.resolvedVersion, packageSpec.pinnedVersion)
)
}
}
@@ -73,7 +73,7 @@ export async function updateProjectManifestObject (
const usedDepType = guessDependencyType(packageSpec.alias, packageManifest) ?? 'dependencies'
if (usedDepType !== 'peerDependencies') {
packageManifest[usedDepType] = packageManifest[usedDepType] ?? {}
packageManifest[usedDepType]![packageSpec.alias] = packageSpec.bareSpecifier
defineDepEntry(packageManifest[usedDepType]!, packageSpec.alias, packageSpec.bareSpecifier)
}
}
}
@@ -87,10 +87,40 @@ export async function updateProjectManifestObject (
function findSpec (alias: string, manifest: ProjectManifest): string | undefined {
const foundDepType = guessDependencyType(alias, manifest)
return foundDepType && manifest[foundDepType]![alias]
if (foundDepType == null) return undefined
const deps = manifest[foundDepType]!
return Object.hasOwn(deps, alias) ? deps[alias] : undefined
}
export function guessDependencyType (alias: string, manifest: ProjectManifest): DependenciesOrPeersField | undefined {
return DEPENDENCIES_OR_PEER_FIELDS
.find((depField) => manifest[depField]?.[alias] === '' || Boolean(manifest[depField]?.[alias]))
return DEPENDENCIES_OR_PEER_FIELDS.find((depField) => {
const deps = manifest[depField]
if (deps == null || !Object.hasOwn(deps, alias)) return false
return deps[alias] === '' || Boolean(deps[alias])
})
}
/**
* Write a dependency entry without risking prototype pollution: even when the
* alias matches a name like `__proto__`, `Object.defineProperty` creates a
* regular own data property rather than reaching through the setter.
*/
function defineDepEntry (target: Record<string, string>, alias: string, value: string): void {
Object.defineProperty(target, alias, {
value,
enumerable: true,
writable: true,
configurable: true,
})
}
/**
* Mirror of `defineDepEntry` for deletes. The `Object.hasOwn` guard keeps the
* `delete` from reaching into the prototype chain when the alias matches an
* inherited property like `constructor`.
*/
function deleteDepEntry (target: Record<string, string> | undefined, alias: string): void {
if (target != null && Object.hasOwn(target, alias)) {
delete target[alias]
}
}

View File

@@ -139,6 +139,33 @@ test('peer dependencies keep prerelease resolved version without prefix', async
})
})
test('writes prototype-conflicting aliases as own data properties without polluting Object.prototype', async () => {
const protoSnapshotBefore = Object.getOwnPropertyNames(Object.prototype).sort()
const manifest = await updateProjectManifestObject('/project', {}, [
{ alias: '__proto__', bareSpecifier: '1.0.0', saveType: 'dependencies' },
{ alias: 'constructor', bareSpecifier: '1.0.1', saveType: 'dependencies' },
{ alias: 'prototype', bareSpecifier: '1.0.2', saveType: 'dependencies' },
{ alias: 'real-pkg', bareSpecifier: '2.0.0', saveType: 'dependencies' },
])
// Each pollution-key alias is stored as a regular own data property.
const deps = manifest.dependencies!
expect(Object.hasOwn(deps, '__proto__')).toBe(true)
expect(Object.hasOwn(deps, 'constructor')).toBe(true)
expect(Object.hasOwn(deps, 'prototype')).toBe(true)
expect(Object.hasOwn(deps, 'real-pkg')).toBe(true)
// The own __proto__ data property shadows the inherited getter and returns the value.
expect(deps.__proto__).toBe('1.0.0')
expect(deps.constructor as unknown as string).toBe('1.0.1')
expect(deps.prototype as unknown as string).toBe('1.0.2')
// The prototype chain of `deps` is unchanged (the assignment did not run __proto__'s setter).
expect(Object.getPrototypeOf(deps)).toBe(Object.prototype)
// Object.prototype hasn't grown a new property.
expect(Object.getOwnPropertyNames(Object.prototype).sort()).toStrictEqual(protoSnapshotBefore)
})
test('peer dependencies respect pinned version "patch" and "none"', async () => {
const cases = [
{ pinnedVersion: 'patch' as const, expected: '3.2.1' },

View File

@@ -159,7 +159,7 @@ describe('determineProvenance', () => {
)
expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: expect.stringContaining(`/-/package/${packageName.replace('/', '%2f')}/visibility`),
href: expect.stringContaining(`/-/package/${packageName.replaceAll('/', '%2f')}/visibility`),
}),
expectedOptions
)
@@ -485,7 +485,7 @@ describe('determineProvenance', () => {
expect(mockFetch.mock.calls).toStrictEqual([[
expect.objectContaining({
href: expect.stringContaining(packageName.replace('/', '%2f')),
href: expect.stringContaining(packageName.replaceAll('/', '%2f')),
}),
expect.anything(),
]])

View File

@@ -11,6 +11,7 @@ import type { FetchFromRegistry, RetryTimeoutOptions } from '@pnpm/fetching.type
import { globalWarn } from '@pnpm/logger'
import type { PackageMeta } from '@pnpm/resolving.registry.types'
import * as retry from '@zkochan/retry'
import semver from 'semver'
interface RegistryResponse {
status: number
@@ -33,10 +34,6 @@ export interface FetchMetadataNotModifiedResult {
notModified: true
}
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/use-ignore-case
const semverRegex = /(.*)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
export class RegistryResponseError extends FetchError {
public readonly pkgName: string
@@ -48,9 +45,9 @@ export class RegistryResponseError extends FetchError {
let hint: string | undefined
if (response.status === 404) {
hint = `${pkgName} is not in the npm registry, or you have no permission to fetch it.`
const matched = pkgName.match(semverRegex)
if (matched != null) {
hint += ` Did you mean ${matched[1]}?`
const nameWithoutVersion = stripTrailingSemverSuffix(pkgName)
if (nameWithoutVersion != null) {
hint += ` Did you mean ${nameWithoutVersion}?`
}
}
super(request, response, hint)
@@ -58,6 +55,51 @@ export class RegistryResponseError extends FetchError {
}
}
/**
* Detect when a package name accidentally includes a `<version>` suffix
* (e.g. `lodash@4.17.21` or `lodash4.17.21`) and return the part before the
* version. Returns `undefined` when no semver suffix is present.
*
* Implemented as an O(n) scan to avoid polynomial backtracking on adversarial
* input (CodeQL: js/polynomial-redos).
*/
function stripTrailingSemverSuffix (pkgName: string): string | undefined {
// Common case: "name@version" split on the rightmost '@'.
// `atIdx > 0` rules out the leading '@' of scoped names like '@scope/foo'.
const atIdx = pkgName.lastIndexOf('@')
if (atIdx > 0 && semver.valid(pkgName.slice(atIdx + 1)) != null) {
return pkgName.slice(0, atIdx)
}
// Fallback: detect a trailing "<digits>.<digits>.<digits>" appended to a name
// with no separator (e.g. "foo1.0.0"). We walk backwards through three
// digit-blocks separated by dots; this is O(n) and free of regex backtracking.
let i = pkgName.length
i = consumeTrailingDigits(pkgName, i)
if (i === pkgName.length || i === 0 || pkgName.charCodeAt(i - 1) !== 46 /* '.' */) return undefined
i--
const beforePatch = i
i = consumeTrailingDigits(pkgName, i)
if (i === beforePatch || i === 0 || pkgName.charCodeAt(i - 1) !== 46) return undefined
i--
const beforeMinor = i
i = consumeTrailingDigits(pkgName, i)
if (i === beforeMinor || i === 0) return undefined
if (semver.valid(pkgName.slice(i)) == null) return undefined
let prefix = pkgName.slice(0, i)
if (prefix.endsWith('@')) prefix = prefix.slice(0, -1)
return prefix.length > 0 ? prefix : undefined
}
function consumeTrailingDigits (s: string, end: number): number {
let i = end
while (i > 0) {
const c = s.charCodeAt(i - 1)
if (c < 48 || c > 57) break
i--
}
return i
}
export interface FetchMetadataFromFromRegistryOptions {
fetch: FetchFromRegistry
retry: RetryTimeoutOptions

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from '@jest/globals'
import { RegistryResponseError } from '../src/fetch.js'
const request = { url: 'https://registry.npmjs.org/foo' }
const notFoundResponse = { status: 404, statusText: 'Not Found' }
describe('RegistryResponseError hint', () => {
test('suggests stripped name when pkg has "@<version>" suffix', () => {
const err = new RegistryResponseError(request, notFoundResponse, 'lodash@4.17.21')
expect(err.hint).toContain('Did you mean lodash?')
})
test('suggests stripped name when pkg has trailing "X.Y.Z" without "@"', () => {
const err = new RegistryResponseError(request, notFoundResponse, 'lodash4.17.21')
expect(err.hint).toContain('Did you mean lodash?')
})
test('handles scoped names with version suffix', () => {
const err = new RegistryResponseError(request, notFoundResponse, '@scope/foo@1.2.3')
expect(err.hint).toContain('Did you mean @scope/foo?')
})
test('does not add a suggestion for bare scoped names', () => {
const err = new RegistryResponseError(request, notFoundResponse, '@scope/foo')
expect(err.hint).not.toMatch(/Did you mean/)
})
test('does not add a suggestion when the prefix would be empty', () => {
const err = new RegistryResponseError(request, notFoundResponse, '1.0.0')
expect(err.hint).not.toMatch(/Did you mean/)
})
test('does not add a suggestion for a plain unversioned name', () => {
const err = new RegistryResponseError(request, notFoundResponse, 'foo')
expect(err.hint).not.toMatch(/Did you mean/)
})
test('does not emit a hint on non-404 responses', () => {
const err = new RegistryResponseError(
request,
{ status: 500, statusText: 'Internal Server Error' },
'foo@1.0.0'
)
expect(err.hint ?? '').not.toMatch(/Did you mean/)
})
test('stays linear under adversarial input', () => {
// The previous implementation used a regex with super-linear backtracking.
// 10k repetitions of "1" should still complete promptly.
const pkgName = '1'.repeat(10_000)
const start = Date.now()
const err = new RegistryResponseError(request, notFoundResponse, pkgName)
const elapsed = Date.now() - start
expect(elapsed).toBeLessThan(1_000)
// The all-digits input has no usable name prefix, so no suggestion is added.
expect(err.hint).not.toMatch(/Did you mean/)
})
})