mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-21 21:22:26 -04:00
## 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.
239 lines
9.7 KiB
TypeScript
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
|
|
}
|