mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-31 12:10:49 -04:00
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:
9
.changeset/codeql-security-fixes.md
Normal file
9
.changeset/codeql-security-fixes.md
Normal 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.
|
||||
@@ -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}"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
]])
|
||||
|
||||
@@ -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
|
||||
|
||||
59
resolving/npm-resolver/test/registryResponseError.test.ts
Normal file
59
resolving/npm-resolver/test/registryResponseError.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user