diff --git a/README.md b/README.md index dd32b4ad0..34ddc3216 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,19 @@ same major line. Should you need to upgrade to a new major, use an explicit environment variables are required and as plain text. If you want to send an empty password, explicitly set `COREPACK_NPM_PASSWORD` to an empty string. +- `COREPACK_ON_UNVERIFIED_DOWNLOAD` can be set to: + - `warn` (case insensitive): attempting to download an unsigned version without + providing a hash will emit a warning to stderr. + - `error` (case insensitive): attempting to download an unsigned version without + providing a hash will fail with an error, and nothing gets downloaded. + - `strict-warn` (case insensitive): same as `warn`, and additionally emits a + warning when downloading a version that is not pinned by a hash, even when + its signature can be verified. + - `strict-error` (case insensitive): same as `error`, and additionally fails + when downloading a version that is not pinned by a hash, even when its + signature can be verified. + - `ignore` (or any other unsupported value): disables that security feature. + - `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` are supported through [`NODE_USE_ENV_PROXY=1`](https://nodejs.org/api/cli.html#node_use_env_proxy1). diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index e80b43150..d38df1c7b 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -231,6 +231,31 @@ export async function installVersion(installTarget: string, locator: Locator, {s } } + const registry = getRegistryFromPackageManagerSpec(spec); + const canVerifySignature = registry.type === `npm` && !registry.bin && !shouldSkipIntegrityCheck(); + if (!build[1]) { + const {COREPACK_ON_UNVERIFIED_DOWNLOAD} = process.env; + debugUtils.log(`No hash provided${canVerifySignature ? `` : `, and signature cannot be verified`}; checking COREPACK_ON_UNVERIFIED_DOWNLOAD, set to: ${COREPACK_ON_UNVERIFIED_DOWNLOAD}`); + const mode = COREPACK_ON_UNVERIFIED_DOWNLOAD?.toUpperCase(); + // In strict mode, a hash is required even when the signature can be verified. + const isStrict = mode === `STRICT-ERROR` || mode === `STRICT-WARN`; + if (isStrict || !canVerifySignature) { + const reason = canVerifySignature + ? `is not pinned by a hash` + : `could not be verified`; + + switch (mode) { + case `ERROR`: + case `STRICT-ERROR`: + throw new Error(`Integrity of ${locator.name}@${version} ${reason}. Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable. Please provide a hash.`); + + case `WARN`: + case `STRICT-WARN`: + console.warn(`Integrity of ${locator.name}@${version} ${reason}. Consider providing a hash. Set COREPACK_ON_UNVERIFIED_DOWNLOAD to 'ignore' to remove this warning.`); + } + } + } + let url: string; let signatures: Array<{keyid: string, sig: string}>; let integrity: string; @@ -238,7 +263,6 @@ export async function installVersion(installTarget: string, locator: Locator, {s if (locatorIsASupportedPackageManager) { url = spec.url.replace(`{}`, version); if (process.env.COREPACK_NPM_REGISTRY) { - const registry = getRegistryFromPackageManagerSpec(spec); if (registry.type === `npm`) { ({tarball: url, signatures, integrity} = await npmRegistryUtils.fetchTarballURLAndSignature(registry.package, version)); if (registry.bin) { @@ -294,19 +318,18 @@ export async function installVersion(installTarget: string, locator: Locator, {s } } - if (!build[1]) { - const registry = getRegistryFromPackageManagerSpec(spec); - if (registry.type === `npm` && !registry.bin && !shouldSkipIntegrityCheck()) { - if (signatures! == null || integrity! == null) - ({signatures, integrity} = (await npmRegistryUtils.fetchTarballURLAndSignature(registry.package, version))); - - await npmRegistryUtils.verifySignature({signatures, integrity, packageName: registry.package, version}); - // @ts-expect-error ignore readonly - build[1] = Buffer.from(integrity.slice(`sha512-`.length), `base64`).toString(`hex`); - } + if (!build[1] && canVerifySignature && registry.type === `npm`) { + if (signatures! == null || integrity! == null) + ({signatures, integrity} = (await npmRegistryUtils.fetchTarballURLAndSignature(registry.package, version))); + + npmRegistryUtils.verifySignature({signatures, integrity, packageName: registry.package, version}); + // @ts-expect-error ignore readonly + build[1] = Buffer.from(integrity.slice(`sha512-`.length), `base64`).toString(`hex`); } - if (build[1] && actualHash !== build[1]) + if (build[1] && actualHash !== build[1]) { + await fs.promises.rm(tmpFolder, {recursive: true, force: true}); throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`); + } const serializedHash = `${algo}.${actualHash}`; diff --git a/tests/Up.test.ts b/tests/Up.test.ts index 1934202b9..5ccbfb22a 100644 --- a/tests/Up.test.ts +++ b/tests/Up.test.ts @@ -24,9 +24,11 @@ describe(`UpCommand`, () => { packageManager: `yarn@2.1.0`, }); + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `warn`; + await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ exitCode: 0, - stderr: ``, + stderr: `Integrity of yarn@2.4.3 could not be verified. Consider providing a hash. Set COREPACK_ON_UNVERIFIED_DOWNLOAD to 'ignore' to remove this warning.\n`, stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), }); diff --git a/tests/Use.test.ts b/tests/Use.test.ts index 4010bf335..6dd37939e 100644 --- a/tests/Use.test.ts +++ b/tests/Use.test.ts @@ -136,9 +136,11 @@ describe(`UseCommand`, () => { const subfolder = ppath.join(cwd, `subfolder`); await xfs.mkdirPromise(subfolder); + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `warn`; + await expect(runCli(subfolder, [`use`, `yarn@2.2.2`])).resolves.toMatchObject({ exitCode: 0, - stderr: ``, + stderr: `Integrity of yarn@2.2.2 could not be verified. Consider providing a hash. Set COREPACK_ON_UNVERIFIED_DOWNLOAD to 'ignore' to remove this warning.\n`, }); await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, diff --git a/tests/main.test.ts b/tests/main.test.ts index e6f7d7200..e00086885 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -856,7 +856,7 @@ it(`should support disabling the network accesses from the environment`, async ( await xfs.mktempPromise(async cwd => { await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { - packageManager: `yarn@2.2.2`, + packageManager: `yarn@2.2.2+sha1.9aede2626b101719cbc1314d61def0742852ba11`, }); await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ @@ -881,12 +881,12 @@ describe(`read-only and offline environment`, () => { // Prepare fake project await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { - packageManager: `yarn@2.2.2`, + packageManager: `yarn@2.2.2+sha1.9aede2626b101719cbc1314d61def0742852ba11`, }); // $ corepack install await expect(runCli(cwd, [`install`])).resolves.toMatchObject({ - stdout: `Adding yarn@2.2.2 to the cache...\n`, + stdout: `Adding yarn@2.2.2+sha1.9aede2626b101719cbc1314d61def0742852ba11 to the cache...\n`, stderr: ``, exitCode: 0, }); @@ -1549,6 +1549,196 @@ describe(`should pick up COREPACK_INTEGRITY_KEYS from env`, () => { }); }); +describe(`unverified downloads`, () => { + beforeEach(() => { + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; // See `_registryServer.mjs` + process.env.COREPACK_DEFAULT_TO_LATEST = `1`; + process.env.COREPACK_INTEGRITY_KEYS = `0`; + }); + + it(`from env variable`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {}); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `error`; + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable`), + }); + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable`), + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `ignore`; + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, // No warning expected + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `warn`; + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`Integrity of pnpm@1.9998.9999 could not be verified.`), + }); + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, // Already cached, no warning expected + }); + }); + }); + + it(`from .corepack.env file`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {}); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ON_UNVERIFIED_DOWNLOAD=error\n`); + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable`), + }); + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable`), + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ON_UNVERIFIED_DOWNLOAD=ignore\n`); + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, // No warning expected + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ON_UNVERIFIED_DOWNLOAD=warn\n`); + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`Integrity of pnpm@1.9998.9999 could not be verified.`), + }); + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, // Already cached, no warning expected + }); + }); + }); + + it(`from env file defined by COREPACK_ENV_FILE`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ON_UNVERIFIED_DOWNLOAD=error\n`); + await xfs.writeFilePromise(ppath.join(cwd, `.other.env` as Filename), `COREPACK_ON_UNVERIFIED_DOWNLOAD=warn\n`); + + // By default, Corepack should be using .corepack.env and fail. + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable`), + }); + + process.env.COREPACK_ENV_FILE = `.other.env`; + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`Integrity of pnpm@1.9998.9999 could not be verified.`), + }); + }); + }); + + it(`from env even if there's a .corepack.env file`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {}); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ON_UNVERIFIED_DOWNLOAD=error\n`); + + // By default, Corepack should be using .corepack.env (or the built-in ones on Node.js 18.x) and fail. + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable`), + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `warn`; + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`Integrity of pnpm@1.9998.9999 could not be verified`), + }); + }); + }); +}); + +describe(`downloads not pinned by a hash`, () => { + it(`should not warn in non-strict mode when the signature can be verified`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `warn`; + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + stderr: ``, + }); + }); + }); + + it(`should warn when COREPACK_ON_UNVERIFIED_DOWNLOAD is set to strict-warn`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `strict-warn`; + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + stderr: `Integrity of yarn@1.22.4 is not pinned by a hash. Consider providing a hash. Set COREPACK_ON_UNVERIFIED_DOWNLOAD to 'ignore' to remove this warning.\n`, + }); + }); + }); + + it(`should fail when COREPACK_ON_UNVERIFIED_DOWNLOAD is set to STRICT-ERROR`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `STRICT-ERROR`; + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`Integrity of yarn@1.22.4 is not pinned by a hash. Downloading unverified versions is disabled by the COREPACK_ON_UNVERIFIED_DOWNLOAD env variable.`), + }); + }); + }); + + it(`should not interfere with versions pinned by a hash in strict mode`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4+sha224.0d6eecaf4d82ec12566fdd97143794d0f0c317e0d652bd4d1b305430`, + }); + + process.env.COREPACK_ON_UNVERIFIED_DOWNLOAD = `strict-error`; + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + stderr: ``, + }); + }); + }); +}); + for (const authType of [`COREPACK_NPM_REGISTRY`, `COREPACK_NPM_TOKEN`, `COREPACK_NPM_PASSWORD`, `PROXY`]) { describe(`custom registry with auth ${authType}`, () => { beforeEach(() => { @@ -1741,8 +1931,8 @@ describe(`handle integrity checks`, () => { }); await expect(runCli(cwd, [`use`, `pnpm`], true)).resolves.toMatchObject({ exitCode: 1, - stdout: expect.stringContaining(`Signature does not match`), - stderr: ``, + stderr: expect.stringContaining(`Signature does not match`), + stdout: `Installing pnpm@1.9998.9999 in the project...\n`, }); }); }); @@ -1757,8 +1947,8 @@ describe(`handle integrity checks`, () => { }); await expect(runCli(cwd, [`use`, `yarn@1.9998.9999`], true)).resolves.toMatchObject({ exitCode: 1, - stdout: expect.stringContaining(`Signature does not match`), - stderr: ``, + stderr: expect.stringContaining(`Signature does not match`), + stdout: `Installing yarn@1.9998.9999 in the project...\n`, }); }); });