Files
pnpm/resolving/default-resolver/src/index.ts
Zoltan Kochan 6d17b669b4 fix: verify lockfile tarball URL matches registry metadata (#12134)
## What

The lockfile resolution verifier now confirms that a registry entry pinning an explicit `tarball` URL points at the artifact the registry's own metadata lists for that `name@version`. A mismatch — or any entry that can't be confirmed against the registry — is rejected with `ERR_PNPM_TARBALL_URL_MISMATCH`.

## Why

Follow-up to the design discussion on #12122. The verifier checked the age/trust of `name@version` against the registry packument but never bound the lockfile's `tarball` URL to it. For the non-standard entries pnpm preserves a tarball URL for (npm Enterprise, GitHub Packages — see `toLockfileResolution`), pnpm fetches straight from that URL. So a **tampered lockfile could pair a trusted `name@version` with an attacker-chosen tarball URL** (plus a matching integrity for the attacker's bytes); verification passed against the legitimate version while the install fetched the attacker's bytes. Defending a checked-in lockfile is explicitly in this feature's threat model.

## How

- For a registry-keyed entry that pins an explicit `tarball`, fetch the packument and assert the URL equals `versions[v].dist.tarball`. The comparison canonicalizes away benign differences — http/https scheme, default ports (`:443`/`:80`), and `%2f` scope-separator encoding (case-insensitive) — so only real mismatches are flagged. The packument is fetched from the user's configured registry (the lockfile's tarball host can't redirect it), and named-registry routing uses the same canonicalization so a scheme/`%2f`-only difference doesn't route to the wrong packument.
- **The binding is unconditional.** It runs regardless of `minimumReleaseAge`/`trustPolicy` and is **not** narrowed by their exclude lists, because it guards *integrity*, not *maturity/trust*. Disabling the age/trust policies must not silently disable anti-tamper. (`createNpmResolutionVerifier` therefore always returns a verifier.)
- **It is fail-closed.** An entry passes only when the registry metadata affirmatively lists the version with a matching tarball URL. If the metadata can't be fetched, doesn't list the version, or omits `dist.tarball`, the entry is rejected — otherwise a tampered lockfile could smuggle a malicious URL past the check by pointing it at a `name@version` the registry can't vouch for.
  - **Behavior change:** as a result, an install that re-verifies a lockfile (its content changed since the last verified run, so the verification cache no longer short-circuits) now requires the configured registry to be reachable. `trustLockfile` is the opt-out for environments that treat the on-disk lockfile as already trusted.
- **Verification cache.** The policy snapshot records a `tarballUrlBinding` marker and `canTrustPastCheck` requires it, so a cache record written before this rule existed is re-verified rather than trusted (closing an upgrade-time bypass).
- Entries with no explicit `tarball` reconstruct the URL from name+version+registry and are inherently bound (no check). `file:`/git-hosted resolutions stay out of scope (#12122).
- Threads `nonSemverVersion` to the verifier so URL-keyed tarball deps (a remote `https:` tarball that carries a semver `version` copied from its manifest) are recognized as deliberate non-registry deps and skipped — also fixing a latent release-age over-match on them. The candidate dedupe key includes `nonSemverVersion` so a registry snapshot and a URL-keyed snapshot sharing a `name@version` and serialized resolution stay distinct.

Mirrored in pacquet (`create_npm_resolution_verifier`). The dedupe-key change is TS-only: pacquet's candidate `version` comes from the lockfile key suffix, so the two shapes never share a key there.

## Tests

- TS: confirmed mismatch → violation; non-standard URL matching metadata → pass; default-port/scheme difference → pass; URL-keyed dep → skipped; URL binding runs (and fails closed) with no age/trust policy configured; `canTrustPastCheck` rejects a cache record lacking the binding marker. Regression-verified (the mismatch test fails when the check is disabled).
- pacquet: mirror tests + the no-policy / `minimumReleaseAge: 0` / `trustPolicy: off` cases, default-port/scheme equivalence, and the missing-`tarballUrlBinding` cache rejection. A few install-dispatch / resolution-reuse tests that pin a deliberately bogus tarball URL (or run against an unreachable registry to prove resolution reuse) now set `trustLockfile`, since the always-on fail-closed tarball-URL check would otherwise flag the fixture before the path under test runs.
- `clippy --deny warnings`, `fmt`, and `dylint` clean.
2026-06-02 15:28:21 +02:00

239 lines
9.7 KiB
TypeScript

import { type BunRuntimeResolveResult, resolveBunRuntime, resolveLatestBunRuntime } from '@pnpm/engine.runtime.bun-resolver'
import { type DenoRuntimeResolveResult, resolveDenoRuntime, resolveLatestDenoRuntime } from '@pnpm/engine.runtime.deno-resolver'
import { type NodeRuntimeResolveResult, resolveLatestNodeRuntime, resolveNodeRuntime } from '@pnpm/engine.runtime.node-resolver'
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry, GetAuthHeader } from '@pnpm/fetching.types'
import { checkCustomResolverCanResolve, type CustomResolver } from '@pnpm/hooks.types'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createGitResolver, type GitResolveResult, resolveLatestFromGit } from '@pnpm/resolving.git-resolver'
import { type LocalResolveResult, resolveFromLocalPath, resolveFromLocalScheme, resolveLatestFromLocal } from '@pnpm/resolving.local-resolver'
import {
createDefaultPackageMetaCache,
createNpmResolutionVerifier,
type CreateNpmResolutionVerifierOptions,
createNpmResolver,
type JsrResolveResult,
type NamedRegistryResolveResult,
type NpmResolveResult,
type PackageMeta,
type PackageMetaCache,
type ResolveFromNpmOptions,
type ResolverFactoryOptions,
type WorkspaceResolveResult,
} from '@pnpm/resolving.npm-resolver'
import type {
LatestInfo,
LatestQuery,
ResolutionVerifier,
ResolveFunction,
ResolveOptions,
ResolveResult,
WantedDependency,
} from '@pnpm/resolving.resolver-base'
import { resolveFromTarball, resolveLatestFromTarball, type TarballResolveResult } from '@pnpm/resolving.tarball-resolver'
import type { RegistryConfig } from '@pnpm/types'
export {
createDefaultPackageMetaCache,
}
export type {
PackageMeta,
PackageMetaCache,
ResolveFunction,
ResolverFactoryOptions,
}
export interface CustomResolverResolveResult extends ResolveResult {
resolvedVia: 'custom-resolver'
}
export type DefaultResolveResult =
| NpmResolveResult
| JsrResolveResult
| NamedRegistryResolveResult
| GitResolveResult
| LocalResolveResult
| TarballResolveResult
| WorkspaceResolveResult
| NodeRuntimeResolveResult
| DenoRuntimeResolveResult
| BunRuntimeResolveResult
| CustomResolverResolveResult
export type DefaultResolver = (wantedDependency: WantedDependency, opts: ResolveOptions) => Promise<DefaultResolveResult>
async function resolveFromCustomResolvers (
customResolvers: CustomResolver[],
wantedDependency: WantedDependency,
opts: ResolveOptions
): Promise<DefaultResolveResult | null> {
if (!customResolvers || customResolvers.length === 0) {
return null
}
for (const customResolver of customResolvers) {
// Skip custom resolvers that don't support both canResolve and resolve
if (!customResolver.canResolve || !customResolver.resolve) continue
// eslint-disable-next-line no-await-in-loop
const canResolve = await checkCustomResolverCanResolve(customResolver, wantedDependency)
if (canResolve) {
// eslint-disable-next-line no-await-in-loop
const result = await customResolver.resolve(wantedDependency, {
lockfileDir: opts.lockfileDir,
projectDir: opts.projectDir,
preferredVersions: (opts.preferredVersions ?? {}) as unknown as Record<string, string>,
currentPkg: opts.currentPkg,
})
return {
...result,
resolvedVia: 'custom-resolver',
} as DefaultResolveResult
}
}
return null
}
export type ResolveLatestDispatcher = (query: LatestQuery, opts: ResolveOptions) => Promise<LatestInfo | undefined>
export function createResolver (
fetchFromRegistry: FetchFromRegistry,
getAuthHeader: GetAuthHeader,
pnpmOpts: ResolverFactoryOptions & {
nodeDownloadMirrors?: Record<string, string>
customResolvers?: CustomResolver[]
}
): { resolve: DefaultResolver, resolveLatest: ResolveLatestDispatcher, clearCache: () => void } {
const {
resolveFromNpm,
resolveFromJsr,
resolveFromNamedRegistry,
resolveLatestFromNpm,
resolveLatestFromJsr,
resolveLatestFromNamedRegistry,
clearCache,
} = createNpmResolver(fetchFromRegistry, getAuthHeader, pnpmOpts)
const resolveFromGit = createGitResolver(pnpmOpts)
const localCtx = { preserveAbsolutePaths: pnpmOpts.preserveAbsolutePaths }
const _resolveFromLocalScheme = resolveFromLocalScheme.bind(null, localCtx)
const _resolveFromLocalPath = resolveFromLocalPath.bind(null, localCtx)
const _resolveNodeRuntime = resolveNodeRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, nodeDownloadMirrors: pnpmOpts.nodeDownloadMirrors })
const _resolveDenoRuntime = resolveDenoRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, resolveFromNpm })
const _resolveBunRuntime = resolveBunRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, resolveFromNpm })
const _resolveLatestNodeRuntime = resolveLatestNodeRuntime.bind(null, { fetchFromRegistry, nodeDownloadMirrors: pnpmOpts.nodeDownloadMirrors })
const _resolveLatestDenoRuntime = resolveLatestDenoRuntime.bind(null, { resolveFromNpm })
const _resolveLatestBunRuntime = resolveLatestBunRuntime.bind(null, { resolveFromNpm })
const _resolveFromCustomResolvers = pnpmOpts.customResolvers
? resolveFromCustomResolvers.bind(null, pnpmOpts.customResolvers)
: null
return {
resolve: async (wantedDependency, opts) => {
const resolution = await _resolveFromCustomResolvers?.(wantedDependency, opts) ??
await resolveFromNpm(wantedDependency, opts as ResolveFromNpmOptions) ??
await resolveFromJsr(wantedDependency, opts as ResolveFromNpmOptions) ??
(wantedDependency.bareSpecifier && (
await resolveFromGit(wantedDependency as { bareSpecifier: string }, opts) ??
await resolveFromTarball(fetchFromRegistry, wantedDependency as { bareSpecifier: string }) ??
await _resolveFromLocalScheme(wantedDependency as { bareSpecifier: string }, opts)
)) ??
await _resolveNodeRuntime(wantedDependency, opts) ??
await _resolveDenoRuntime(wantedDependency, opts) ??
await _resolveBunRuntime(wantedDependency, opts) ??
// Named-registry runs between the explicit local schemes above and the
// path-shape match below, so `<alias>:@scope/pkg` reaches the configured
// registry while a colliding `file:`/`link:`/`workspace:` alias cannot
// hijack the built-in protocols.
await resolveFromNamedRegistry(wantedDependency, opts as ResolveFromNpmOptions) ??
(wantedDependency.bareSpecifier
? await _resolveFromLocalPath(wantedDependency as { bareSpecifier: string }, opts)
: null)
if (!resolution) {
let specifier = `${wantedDependency.alias ? wantedDependency.alias + '@' : ''}${wantedDependency.bareSpecifier ?? ''}`
if (specifier !== '') {
specifier = `"${specifier}"`
}
throw new PnpmError(
'SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER',
`${specifier} isn't supported by any available resolver.`)
}
return resolution
},
resolveLatest: async (query, opts) => {
const info = (await resolveLatestFromNpm(query, opts)) ??
(await resolveLatestFromJsr(query, opts)) ??
(await resolveLatestFromGit(query)) ??
(await resolveLatestFromTarball(query)) ??
(await resolveLatestFromLocal(query)) ??
(await _resolveLatestNodeRuntime(query, opts)) ??
(await _resolveLatestDenoRuntime(query, opts)) ??
(await _resolveLatestBunRuntime(query, opts)) ??
(await resolveLatestFromNamedRegistry(query, opts))
return info
},
clearCache,
}
}
export type ResolutionVerifierFactoryOptions =
& Pick<ResolverFactoryOptions, 'cacheDir' | 'registries' | 'namedRegistries' | 'retry' | 'timeout' | 'fetchWarnTimeoutMs'>
& Pick<CreateNpmResolutionVerifierOptions,
| 'minimumReleaseAge'
| 'minimumReleaseAgeStrict'
| 'minimumReleaseAgeExclude'
| 'ignoreMissingTimeField'
| 'trustPolicy'
| 'trustPolicyExclude'
| 'trustPolicyIgnoreAfter'
| 'metaCache'
| 'now'
> & {
configByUri?: Record<string, RegistryConfig>
}
/**
* Companion to {@link createResolver}. Collects the resolver-specific
* verifier factories (today: npm) into a list. The npm verifier is
* always present — it enforces the tarball-URL binding regardless of
* policy configuration — so the list is non-empty.
*
* Future protocols (jsr, git, attestation, etc.) plug in here by pushing
* their own `ResolutionVerifier` onto the list. Each verifier handles
* its own protocol short-circuit inside `verify` (returns `{ ok: true }`
* for resolutions outside its scope), so dispatch happens naturally at
* the install side — no combinator needed.
*/
export function createResolutionVerifiers (
fetchFromRegistry: FetchFromRegistry,
opts: ResolutionVerifierFactoryOptions
): ResolutionVerifier[] {
const fetchOpts = {
fetch: fetchFromRegistry,
retry: opts.retry ?? {},
timeout: opts.timeout ?? 60_000,
fetchWarnTimeoutMs: opts.fetchWarnTimeoutMs ?? 10_000,
}
const getAuthHeaderValueByURI = createGetAuthHeaderByURI(opts.configByUri ?? {})
const verifiers: ResolutionVerifier[] = []
const npmVerifier = createNpmResolutionVerifier({
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
ignoreMissingTimeField: opts.ignoreMissingTimeField,
trustPolicy: opts.trustPolicy,
trustPolicyExclude: opts.trustPolicyExclude,
trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
registries: opts.registries,
namedRegistries: opts.namedRegistries,
fetchOpts,
getAuthHeaderValueByURI,
cacheDir: opts.cacheDir,
metaCache: opts.metaCache,
now: opts.now,
})
verifiers.push(npmVerifier)
return verifiers
}