Files
pnpm/resolving/npm-resolver/src/fetch.ts
Zoltan Kochan 50b33c1e6b 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.
2026-05-13 00:50:59 +02:00

217 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import url from 'node:url'
import { requestRetryLogger } from '@pnpm/core-loggers'
import {
FetchError,
type FetchErrorRequest,
type FetchErrorResponse,
PnpmError,
} from '@pnpm/error'
import type { FetchFromRegistry, RetryTimeoutOptions } from '@pnpm/fetching.types'
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
statusText: string
headers: {
get: (name: string) => string | null
}
json: () => Promise<PackageMeta>
text: () => Promise<string>
}
export interface FetchMetadataResult {
meta: PackageMeta
jsonText: string
etag?: string
notModified?: false
}
export interface FetchMetadataNotModifiedResult {
notModified: true
}
export class RegistryResponseError extends FetchError {
public readonly pkgName: string
constructor (
request: FetchErrorRequest,
response: FetchErrorResponse,
pkgName: string
) {
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 nameWithoutVersion = stripTrailingSemverSuffix(pkgName)
if (nameWithoutVersion != null) {
hint += ` Did you mean ${nameWithoutVersion}?`
}
}
super(request, response, hint)
this.pkgName = pkgName
}
}
/**
* 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
timeout: number
fetchWarnTimeoutMs: number
}
export interface FetchMetadataOptions {
registry: string
authHeaderValue?: string
fullMetadata?: boolean
etag?: string
modified?: string
}
export async function fetchMetadataFromFromRegistry (
fetchOpts: FetchMetadataFromFromRegistryOptions,
pkgName: string,
{
authHeaderValue,
etag: cachedEtag,
fullMetadata,
modified: cachedModified,
registry,
}: FetchMetadataOptions
): Promise<FetchMetadataResult | FetchMetadataNotModifiedResult> {
const uri = toUri(pkgName, registry)
const op = retry.operation(fetchOpts.retry)
return new Promise((resolve, reject) => {
op.attempt(async (attempt) => {
let response: RegistryResponse
const startTime = Date.now()
try {
response = await fetchOpts.fetch(uri, {
authHeaderValue,
compress: true,
fullMetadata,
ifNoneMatch: cachedEtag,
ifModifiedSince: cachedModified ? new Date(cachedModified).toUTCString() : undefined,
retry: fetchOpts.retry,
timeout: fetchOpts.timeout,
}) as RegistryResponse
} catch (error: any) { // eslint-disable-line
reject(new PnpmError('META_FETCH_FAIL', `GET ${uri}: ${error.message as string}`, { attempts: attempt, cause: error }))
return
}
if (response.status === 304) {
resolve({ notModified: true })
return
}
if (response.status >= 400) {
const request = {
authHeaderValue,
url: uri,
}
reject(new RegistryResponseError(request, response, pkgName))
return
}
// Here we only retry broken JSON responses.
// Other HTTP issues are retried by the @pnpm/network.fetch library
try {
const jsonText = await response.text()
const meta = JSON.parse(jsonText) as PackageMeta
// Check if request took longer than expected
const elapsedMs = Date.now() - startTime
if (elapsedMs > fetchOpts.fetchWarnTimeoutMs) {
globalWarn(`Request took ${elapsedMs}ms: ${uri}`)
}
resolve({
meta,
jsonText,
etag: response.headers.get('etag') ?? undefined,
})
} catch (error: any) { // eslint-disable-line
const timeout = op.retry(
new PnpmError('BROKEN_METADATA_JSON', error.message)
)
if (timeout === false) {
reject(op.mainError())
return
}
// Extract error properties into a plain object because Error properties
// are non-enumerable and don't serialize well through the logging system
const errorInfo = {
name: error.name,
message: error.message,
code: error.code,
errno: error.errno,
}
requestRetryLogger.debug({
attempt,
error: errorInfo,
maxRetries: fetchOpts.retry.retries!,
method: 'GET',
timeout,
url: uri,
})
}
})
})
}
function toUri (pkgName: string, registry: string): string {
let encodedName: string
if (pkgName[0] === '@') {
encodedName = `@${encodeURIComponent(pkgName.slice(1))}`
} else {
encodedName = encodeURIComponent(pkgName)
}
return new url.URL(encodedName, registry.endsWith('/') ? registry : `${registry}/`).toString()
}