CVE Patch Review

CVE-2026-55700: Root-Cause Fix for pnpm stage download Path Traversal

CVE-2026-55700 · Updated 2026-06-27 Root-cause

Summary

The patch addresses arbitrary file write during `pnpm stage download` by validating manifest-derived `name` and `version` before filename construction, centralizing tarball filename generation, and adding traversal-focused tests. The fix is strong because it removes trust in unvalidated manifest fields and adds both semantic validation and path-boundary checks before writing to disk.

Analysis

Vulnerability

CVE-2026-55700 describes a path traversal and arbitrary file write condition in pnpm stage download. The vulnerable flow derived an output filename directly from untrusted package manifest fields embedded in the downloaded tarball. Specifically, summary.name and summary.version were interpolated into the destination filename with only minimal package-name normalization, and the result was passed to path.resolve(...) before writing the tarball to disk. Because version was not sanitized and name normalization only replaced @ and the first /, attacker-controlled manifest content could introduce path separators or traversal segments and escape the intended download directory.

The vulnerable code path shown in the upstream patch reference at pnpm/pnpm#12303 was:

const filename = `${normalizePackageName(summary.name)}-${summary.version}-${stageId}.tgz`
await fs.writeFile(path.resolve(opts.dir ?? process.cwd(), filename), tarballData)

This is a classic trust-boundary failure: metadata extracted from a remote artifact was treated as safe for filesystem path construction. The CVE context is also reflected in the MITRE CVE record.

Patch

The patch replaces ad hoc filename construction with a centralized helper, createTarballFilename(), and uses explicit validation before any write occurs. The new helper validates package names with validate-npm-package-name, validates versions with semver.valid(), and rejects any resulting filename whose POSIX or Windows basename differs from the full string. The download path is then resolved and checked to ensure its parent directory is exactly the intended download directory.

export function createTarballFilename ({ name, version, suffix }: CreateTarballFilenameOptions): string {
  if (!validateNpmPackageName(name).validForOldPackages) {
    throw new PnpmError('INVALID_PACKAGE_NAME', `Invalid package name "${name}".`)
  }
  if (!valid(version)) {
    throw new PnpmError('INVALID_PACKAGE_VERSION', `Invalid package version "${version}".`)
  }

  const filename = `${normalizePackageName(name)}-${version}${suffix == null ? '' : `-${suffix}`}.tgz`
  if (path.basename(filename) !== filename || path.win32.basename(filename) !== filename) {
    throw new PnpmError('INVALID_TARBALL_FILENAME', `Invalid tarball filename "${filename}".`)
  }
  return filename
}

In stage/download.ts, the write path now becomes:

const filename = createTarballFilename({ name: summary.name, version: summary.version, suffix: stageId })
const downloadDir = path.resolve(opts.dir ?? process.cwd())
const outputPath = path.resolve(downloadDir, filename)
if (path.dirname(outputPath) !== downloadDir) {
  throw new PnpmError('INVALID_TARBALL_FILENAME', `Invalid tarball filename "${filename}".`)
}
await fs.writeFile(outputPath, tarballData)

The patch also removes duplicate filename logic by re-exporting normalizePackageName from the new helper module and updating tarball summarization to use the same safe constructor. Finally, regression tests were added for traversal attempts through both manifest version and name, asserting that no file is created outside the target directory. These changes are documented in the upstream change summary at the patch PR.

Review

Pros

  • The patch fixes the root trust issue by validating manifest-derived name and version before filename generation rather than trying to clean up dangerous strings after the fact.
  • Centralizing filename construction in safeTarballFilename.ts reduces the chance of future call sites reintroducing inconsistent or weaker logic.
  • Using both semantic validation and basename checks is defense in depth: invalid package metadata is rejected early, and any unexpected separator behavior across POSIX or Windows is also screened.
  • The additional path.dirname(outputPath) === downloadDir assertion provides a final containment check at the write boundary.
  • Tests are well targeted: they simulate malicious tarballs and verify both the thrown error codes and the absence of filesystem side effects outside the download directory.
  • The same safe filename generation is applied to tarball summarization, reducing divergence between display and write paths.

Cons

  • normalizePackageName() still performs only simple character replacement, so safety depends primarily on prior package-name validation. That is acceptable here, but the helper remains unsuitable if reused with unvalidated input elsewhere.
  • The directory containment check compares path.dirname(outputPath) to downloadDir, which is effective for single-file writes but narrower than a generalized relative-path containment check. If future logic ever permits nested output paths intentionally, this guard would need redesign.
  • The patch validates name against validForOldPackages. That matches broad npm compatibility, but it is a policy choice; stricter validation could further reduce edge cases if backward compatibility were not required.

Verdict

Root-cause.

This patch addresses the vulnerability at the correct layer by treating tarball manifest metadata as untrusted input and enforcing validity before deriving a filesystem path. It also adds a write-boundary check and regression tests that directly exercise the exploit primitive described in NVD. For the documented issue in pnpm stage download, the remediation is technically sound and materially reduces the chance of recurrence through the same code path.

Sources