mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-01 03:15:15 -04:00
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.
217 lines
6.6 KiB
TypeScript
217 lines
6.6 KiB
TypeScript
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()
|
||
}
|