diff --git a/packages/cli-doctor/src/utils/http.js b/packages/cli-doctor/src/utils/http.js index f73cf0e54..c2fd9dbbf 100644 --- a/packages/cli-doctor/src/utils/http.js +++ b/packages/cli-doctor/src/utils/http.js @@ -3,6 +3,10 @@ import http from 'http'; import net from 'net'; import tls from 'tls'; import { URL } from 'url'; +import { dropDistrustedRoots } from '@percy/client/utils'; + +// probes send PERCY_TOKEN before any other module may have loaded the client utils +dropDistrustedRoots(); // Default request timeout (10 seconds) const DEFAULT_TIMEOUT = 10000; diff --git a/packages/client/src/trust.js b/packages/client/src/trust.js new file mode 100644 index 000000000..d369a586b --- /dev/null +++ b/packages/client/src/trust.js @@ -0,0 +1,86 @@ +import fs from 'fs'; +import tls from 'tls'; +import crypto from 'crypto'; +import logger from '@percy/logger'; + +// SHA-256 hashes of the SubjectPublicKeyInfo of root certificates that Node still bundles but that +// must no longer be trusted. Both chain to eMudhra intermediates whose private keys are publicly +// known (https://emudhra.we-have-a-problem.com): emSign directly, and AAA Certificate Services via +// a Sectigo cross-sign. Browsers and operating systems already distrust them. +export const DISTRUSTED_ROOTS = new Map([ + ['8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', 'emSign ECC Root CA - G3'], + ['bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', 'AAA Certificate Services'] +]); + +// Returns true when a PEM certificate is not one of the distrusted roots. Like Node, certificates +// that cannot be parsed are not trusted. +export function isTrustedCertificate(pem) { + try { + let spki = crypto.createPublicKey(pem).export({ type: 'spki', format: 'der' }); + return !DISTRUSTED_ROOTS.has(crypto.createHash('sha256').update(spki).digest('hex')); + } catch (error) { + logger('client:trust').debug(`Skipping a CA certificate that cannot be parsed: ${error.message}`); + return false; + } +} + +// Returns the CA certificates Node trusts by default, without the distrusted roots. Older versions +// of Node cannot report their defaults, so use the bundled roots plus NODE_EXTRA_CA_CERTS, which +// Node only adds to its default store and would otherwise be lost when providing an explicit list. +export function trustedCertificates() { + if (tls.getCACertificates) { + return tls.getCACertificates('default').filter(isTrustedCertificate); + } + + let extra = []; + + try { + extra = fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS, 'utf-8') + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []; + } catch {} + + return tls.rootCertificates.concat(extra).filter(isTrustedCertificate); +} + +// Returns true when Node verifies against the OpenSSL store instead of its bundled roots. Distro +// builds (e.g. Debian and Ubuntu) default to the OpenSSL store, and either default can be switched +// with --use-openssl-ca or --use-bundled-ca, where the last option wins and command-line options +// take precedence over NODE_OPTIONS. Unlike these, --use-system-ca adds to the bundled roots. +export function usesOpenSSLStore(variables = process.config.variables) { + let openssl = !!variables.node_use_openssl_ca; + let options = [...(process.env.NODE_OPTIONS ?? '').split(/\s+/), ...process.execArgv]; + + for (let option of options) { + // Node accepts both dashes and underscores in option names + option = option.replace(/_/g, '-'); + if (option === '--use-openssl-ca') openssl = true; + if (option === '--use-bundled-ca') openssl = false; + } + + return openssl; +} + +// Removes the distrusted roots from the CA certificates used by every TLS connection in this +// process that does not provide its own `ca` option (http, https, proxies, fetch). +export function dropDistrustedRoots() { + // the OpenSSL store is managed by the OS, and OS vendors have removed the distrusted roots + if (usesOpenSSLStore()) return; + + // newer versions of Node can replace the default CA certificates directly + if (tls.setDefaultCACertificates && tls.getCACertificates) { + let current = tls.getCACertificates('default'); + let trusted = current.filter(isTrustedCertificate); + if (trusted.length !== current.length) tls.setDefaultCACertificates(trusted); + return; + } + + if (tls.createSecureContext.__percyTrusted) return; + let ca = trustedCertificates(); + let { createSecureContext } = tls; + + tls.createSecureContext = function(options) { + return createSecureContext(options?.ca ? options : { ...options, ca }); + }; + + tls.createSecureContext.__percyTrusted = true; +} diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 7fe84ab7f..1735d8f0a 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -5,6 +5,10 @@ import path from 'path'; import crypto from 'crypto'; import logger from '@percy/logger'; import { snakecase } from '@percy/config/utils'; +import { dropDistrustedRoots } from './trust.js'; + +// every network path in the CLI imports these utils, so distrust compromised roots process-wide +dropDistrustedRoots(); // Formats a raw byte integer as a string export function formatBytes(int) { @@ -299,3 +303,11 @@ export { ProxyHttpsAgent, proxyAgentFor } from './proxy.js'; + +export { + DISTRUSTED_ROOTS, + isTrustedCertificate, + trustedCertificates, + usesOpenSSLStore, + dropDistrustedRoots +} from './trust.js'; diff --git a/packages/client/test/unit/trust.test.js b/packages/client/test/unit/trust.test.js new file mode 100644 index 000000000..301b2a00f --- /dev/null +++ b/packages/client/test/unit/trust.test.js @@ -0,0 +1,283 @@ +import fs from 'fs'; +import tls from 'tls'; +import https from 'https'; +import { + request, + DISTRUSTED_ROOTS, + isTrustedCertificate, + trustedCertificates, + usesOpenSSLStore, + dropDistrustedRoots +} from '@percy/client/utils'; + +const ssl = { + cert: fs.readFileSync('./test/certs/test.crt', 'utf-8'), + key: fs.readFileSync('./test/certs/test.key', 'utf-8') +}; + +// newer versions of Node replace the default CA certificates instead of the secure context default +const canSetDefaultCA = !!(tls.setDefaultCACertificates && tls.getCACertificates); + +describe('Unit / Trust', () => { + let distrusted = tls.rootCertificates.filter(pem => !isTrustedCertificate(pem)); + + it('distrusts the roots of the compromised eMudhra intermediates', () => { + expect([...DISTRUSTED_ROOTS.values()]).toEqual([ + 'emSign ECC Root CA - G3', + 'AAA Certificate Services' + ]); + + // every supported Node version still bundles at least the emSign root + expect(distrusted.length).toBeGreaterThan(0); + expect(isTrustedCertificate(ssl.cert)).toBe(true); + }); + + describe('trustedCertificates', () => { + let { NODE_EXTRA_CA_CERTS } = process.env; + let { getCACertificates } = tls; + + beforeEach(() => { + delete process.env.NODE_EXTRA_CA_CERTS; + // exercise the fallback for Node versions that cannot report their default CA certificates + tls.getCACertificates = undefined; + }); + + afterEach(() => { + if (NODE_EXTRA_CA_CERTS == null) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = NODE_EXTRA_CA_CERTS; + tls.getCACertificates = getCACertificates; + }); + + it('filters the CA certificates Node trusts by default when available', () => { + tls.getCACertificates = jasmine.createSpy('getCACertificates') + .and.returnValue([ssl.cert, ...distrusted]); + + expect(trustedCertificates()).toEqual([ssl.cert]); + expect(tls.getCACertificates).toHaveBeenCalledOnceWith('default'); + }); + + it('returns the bundled roots without the distrusted roots', () => { + let trusted = trustedCertificates(); + + expect(trusted.length).toEqual(tls.rootCertificates.length - distrusted.length); + expect(trusted).not.toContain(distrusted[0]); + }); + + it('keeps the certificates from NODE_EXTRA_CA_CERTS', () => { + process.env.NODE_EXTRA_CA_CERTS = './test/certs/test.crt'; + expect(trustedCertificates()).toContain(ssl.cert.trim()); + }); + + it('drops distrusted roots found in NODE_EXTRA_CA_CERTS', async () => { + let bundle = `${await import('os').then(os => os.tmpdir())}/percy-extra-ca-${process.pid}.pem`; + fs.writeFileSync(bundle, `${ssl.cert}\n${distrusted.join('\n')}\n`); + process.env.NODE_EXTRA_CA_CERTS = bundle; + + try { + let trusted = trustedCertificates(); + expect(trusted).toContain(ssl.cert.trim()); + expect(trusted.filter(pem => !isTrustedCertificate(pem))).toEqual([]); + } finally { + fs.unlinkSync(bundle); + } + }); + + it('skips certificates in NODE_EXTRA_CA_CERTS that cannot be parsed', async () => { + let bundle = `${await import('os').then(os => os.tmpdir())}/percy-bad-ca-${process.pid}.pem`; + fs.writeFileSync(bundle, `${ssl.cert}\n-----BEGIN CERTIFICATE-----\nnot a certificate\n` + + '-----END CERTIFICATE-----\n'); + process.env.NODE_EXTRA_CA_CERTS = bundle; + + try { + let trusted = trustedCertificates(); + expect(trusted).toContain(ssl.cert.trim()); + expect(trusted.length).toEqual(tls.rootCertificates.length - distrusted.length + 1); + } finally { + fs.unlinkSync(bundle); + } + }); + + it('ignores a NODE_EXTRA_CA_CERTS file without certificates', () => { + process.env.NODE_EXTRA_CA_CERTS = './test/certs/test.key'; + expect(trustedCertificates().length).toEqual(tls.rootCertificates.length - distrusted.length); + }); + + it('ignores a missing NODE_EXTRA_CA_CERTS file', () => { + process.env.NODE_EXTRA_CA_CERTS = './test/certs/missing.crt'; + expect(trustedCertificates().length).toEqual(tls.rootCertificates.length - distrusted.length); + }); + }); + + describe('usesOpenSSLStore', () => { + let { NODE_OPTIONS } = process.env; + let { execArgv } = process; + + beforeEach(() => { + delete process.env.NODE_OPTIONS; + process.execArgv = []; + }); + + afterEach(() => { + if (NODE_OPTIONS == null) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = NODE_OPTIONS; + process.execArgv = execArgv; + }); + + it('follows how Node was built by default', () => { + expect(usesOpenSSLStore()).toBe(!!process.config.variables.node_use_openssl_ca); + expect(usesOpenSSLStore({})).toBe(false); + expect(usesOpenSSLStore({ node_use_openssl_ca: 1 })).toBe(true); + }); + + for (let [options, expected] of [ + ['--use-openssl-ca', true], + ['--use_openssl_ca', true], + ['--use-system-ca', false], + ['--use-openssl-ca --use-bundled-ca', false], + ['--use_bundled_ca --use-openssl-ca', true] + ]) { + it(`returns ${expected} for NODE_OPTIONS="${options}"`, () => { + process.env.NODE_OPTIONS = options; + expect(usesOpenSSLStore({})).toBe(expected); + }); + } + + it('lets --use-bundled-ca override an OpenSSL build default', () => { + process.env.NODE_OPTIONS = '--use-bundled-ca'; + expect(usesOpenSSLStore({ node_use_openssl_ca: 1 })).toBe(false); + }); + + it('lets command-line options take precedence over NODE_OPTIONS', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.execArgv = ['--use-bundled-ca']; + expect(usesOpenSSLStore({})).toBe(false); + }); + }); + + describe('dropDistrustedRoots', () => { + let server; + + beforeAll(async () => { + server = https.createServer(ssl, (req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, resolve)); + }); + + afterAll(async () => { + await new Promise(resolve => server.close(resolve)); + }); + + let get = options => request(`https://localhost:${server.address().port}/`, { + agent: new https.Agent({ maxCachedSessions: 0 }), retries: 0, ...options + }); + + it('removes the distrusted roots when the client utils are imported', () => { + // nothing is changed when Node verifies against the OS-managed OpenSSL store + if (usesOpenSSLStore()) { + expect(tls.createSecureContext.__percyTrusted).toBeUndefined(); + } else if (canSetDefaultCA) { + expect(tls.getCACertificates('default').every(isTrustedCertificate)).toBe(true); + } else { + expect(tls.createSecureContext.__percyTrusted).toBe(true); + } + }); + + it('still verifies certificates against the remaining roots', async () => { + await expectAsync(get()).toBeRejectedWith(jasmine.objectContaining({ + code: 'DEPTH_ZERO_SELF_SIGNED_CERT' + })); + }); + + it('respects an explicitly provided ca', async () => { + await expectAsync(get({ ca: ssl.cert })).toBeResolvedTo('ok'); + }); + + describe('with setDefaultCACertificates', () => { + let { setDefaultCACertificates, getCACertificates } = tls; + let { NODE_OPTIONS } = process.env; + let { execArgv } = process; + + beforeEach(() => { + // use the bundled roots regardless of how Node was built or the tests were run + process.env.NODE_OPTIONS = '--use-bundled-ca'; + process.execArgv = []; + tls.setDefaultCACertificates = jasmine.createSpy('setDefaultCACertificates'); + tls.getCACertificates = jasmine.createSpy('getCACertificates'); + }); + + afterEach(() => { + Object.assign(tls, { setDefaultCACertificates, getCACertificates }); + if (NODE_OPTIONS == null) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = NODE_OPTIONS; + process.execArgv = execArgv; + }); + + it('replaces the default CA certificates without the distrusted roots', () => { + tls.getCACertificates.and.returnValue([ssl.cert, ...distrusted]); + dropDistrustedRoots(); + + expect(tls.getCACertificates).toHaveBeenCalledWith('default'); + expect(tls.setDefaultCACertificates).toHaveBeenCalledOnceWith([ssl.cert]); + }); + + it('leaves the default CA certificates alone without distrusted roots', () => { + tls.getCACertificates.and.returnValue([ssl.cert]); + dropDistrustedRoots(); + + expect(tls.setDefaultCACertificates).not.toHaveBeenCalled(); + }); + }); + + if (!canSetDefaultCA) { + describe('on Node versions without setDefaultCACertificates', () => { + let { createSecureContext } = tls; + let { NODE_OPTIONS } = process.env; + let { execArgv } = process; + + beforeEach(() => { + tls.createSecureContext = jasmine.createSpy('createSecureContext'); + // use the bundled roots regardless of how Node was built or the tests were run + process.env.NODE_OPTIONS = '--use-bundled-ca'; + process.execArgv = []; + }); + + afterEach(() => { + tls.createSecureContext = createSecureContext; + if (NODE_OPTIONS == null) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = NODE_OPTIONS; + process.execArgv = execArgv; + }); + + it('defaults secure contexts to the trusted certificates', () => { + let spy = tls.createSecureContext; + dropDistrustedRoots(); + dropDistrustedRoots(); + + tls.createSecureContext({ servername: 'percy.io' }); + tls.createSecureContext({ ca: ssl.cert }); + + expect(spy).toHaveBeenCalledWith({ servername: 'percy.io', ca: trustedCertificates() }); + expect(spy).toHaveBeenCalledWith({ ca: ssl.cert }); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('still drops the distrusted roots when Node adds the system store', () => { + let spy = tls.createSecureContext; + process.env.NODE_OPTIONS = '--use-bundled-ca --use-system-ca'; + dropDistrustedRoots(); + + expect(tls.createSecureContext).not.toBe(spy); + }); + + for (let flag of ['--use-openssl-ca', '--use_openssl_ca']) { + it(`does nothing when Node uses the OS trust store via ${flag}`, () => { + let spy = tls.createSecureContext; + process.env.NODE_OPTIONS = `--max-old-space-size=4096 ${flag}`; + dropDistrustedRoots(); + + expect(tls.createSecureContext).toBe(spy); + }); + } + }); + } + }); +}); diff --git a/packages/core/src/install.js b/packages/core/src/install.js index 7e4b85fb3..feafc3376 100644 --- a/packages/core/src/install.js +++ b/packages/core/src/install.js @@ -2,6 +2,7 @@ import fs from 'fs'; import url from 'url'; import path from 'path'; import https from 'https'; +import crypto from 'crypto'; import logger from '@percy/logger'; import cp from 'child_process'; import { ProxyHttpsAgent, formatBytes } from '@percy/client/utils'; @@ -44,13 +45,15 @@ export function selectByPlatform(map) { // Downloads and extracts an executable from a url into a local directory, returning the full path // to the extracted binary. Skips installation if the executable already exists at the binary path. +// When a sha256 checksum is provided, the archive is verified against it before being extracted. export async function download({ name, revision, url, extract, directory, - executable + executable, + sha256 }) { let outdir = path.join(directory, revision); if (process.env.NODE_ENV === 'executable') { @@ -81,6 +84,7 @@ export async function download({ await fs.promises.mkdir(outdir, { recursive: true }); // download the file at the given URL + let hash = crypto.createHash('sha256'); await new Promise((resolve, reject) => https.get(url, { agent: new ProxyHttpsAgent() // allow proxied requests }, response => { @@ -103,6 +107,9 @@ export async function download({ }); } + // hash the archive as it is downloaded + response.on('data', chunk => hash.update(chunk)); + // pipe the response directly to a file response.pipe( fs.createWriteStream(archive) @@ -111,6 +118,13 @@ export async function download({ ); }).on('error', reject)); + // never extract an archive that does not match its expected checksum + let checksum = hash.digest('hex'); + if (sha256 && checksum !== sha256) { + throw new Error(`Checksum mismatch for ${name} ${revision}: ` + ( + `expected sha256 ${sha256}, got ${checksum} - ${url}`)); + } + if (process.env.NODE_ENV === 'executable') { let output = cp.execSync(command, { encoding: 'utf-8' }).trim(); let prefix = null; @@ -165,13 +179,18 @@ export function chromium({ darwinArm: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') }); + // checksums are only known for the default revisions + let sha256 = revision === selectByPlatform(chromium.revisions) + ? selectByPlatform(chromium.checksums) : undefined; + return download({ name: 'Chromium', revision, url, extract, directory, - executable + executable, + sha256 }); } @@ -185,5 +204,15 @@ chromium.revisions = { darwinArm: '1536376' }; +// SHA-256 of each platform's archive for the revisions above; update both together. The downloaded +// archive is executed, so it must be verified even when served over TLS or from a custom base URL. +chromium.checksums = { + linux: 'c4bb3ae75dd6c174472b53439d65fa067efdbb4633e30776abf9dac180f92de3', + win64: '6521f300babef0bd0f202a7de0da58e392b8862ab7609ca792505326fb93cd71', + win32: 'a67e4404a0548d650b899e0c92024ed86ef32cf6d15009ffe8295917312d8557', + darwin: 'adead2a2a830d206cfe8b36171fc5ef64eddbe20152528f164c51946c75e5ff8', + darwinArm: '7eb76e4cf39b1a828c4680a4252bd21f608392545409fca4c20220f449804ad8' +}; + // export the namespace by default export * as default from './install.js'; diff --git a/packages/core/test/unit/install.test.js b/packages/core/test/unit/install.test.js index de54c5486..11990d06f 100644 --- a/packages/core/test/unit/install.test.js +++ b/packages/core/test/unit/install.test.js @@ -1,4 +1,5 @@ import path from 'path'; +import crypto from 'crypto'; import logger from '@percy/logger/test/helpers'; import { mockfs, fs } from '@percy/config/test/helpers'; import { mockRequests } from '@percy/client/test/helpers'; @@ -6,6 +7,19 @@ import install from '../../src/install.js'; import { mockgit } from '@percy/env/test/helpers'; const CHROMIUM_REVISIONS = install.chromium.revisions; +const CHROMIUM_CHECKSUMS = install.chromium.checksums; +// sha256 of the empty archive served by default mocked downloads +const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + +// expect the default revisions' archives to be the empty mocked download +function mockChromiumChecksums() { + install.chromium.checksums = Object.fromEntries( + Object.keys(CHROMIUM_CHECKSUMS).map(platform => [platform, EMPTY_SHA256])); +} + +afterEach(() => { + install.chromium.checksums = CHROMIUM_CHECKSUMS; +}); describe('Unit / Install', () => { let dl, options; @@ -112,6 +126,28 @@ describe('Unit / Install', () => { ); }); + it('extracts the archive when it matches the expected checksum', async () => { + dl.and.returnValue([200, 'archive contents']); + options.sha256 = crypto.createHash('sha256').update('archive contents').digest('hex'); + + await install.download(options); + + expect(options.extract).toHaveBeenCalled(); + }); + + it('refuses to extract an archive that does not match the expected checksum', async () => { + dl.and.returnValue([200, 'tampered contents']); + options.sha256 = EMPTY_SHA256; + + await expectAsync(install.download(options)).toBeRejectedWithError( + `Checksum mismatch for Archive v0: expected sha256 ${EMPTY_SHA256}, got ` + + '1ff92f20b7f26122c860265576fa1eeb2dec5e550a955c90a56e0bd9e443a712' + + ' - https://fake-download.org/archive.zip'); + + expect(options.extract).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join('.downloads', 'v0', 'archive.zip'))).toBe(false); + }); + it('returns the full path of the executable', async () => { await expectAsync(install.download(options)).toBeResolvedTo( path.join('.downloads', 'v0', 'extracted', 'bin.exe')); @@ -127,6 +163,7 @@ describe('Unit / Install', () => { // stub the unzip module using esm loader mocks extractZip = jasmine.createSpy('exports').and.resolveTo(); global.__MOCK_IMPORTS__.set('./unzip.js', { default: extractZip }); + mockChromiumChecksums(); // make getters for jasmine property spy let { platform, arch } = process; @@ -150,6 +187,29 @@ describe('Unit / Install', () => { }); }); + it('pins a sha256 checksum for every default revision', () => { + expect(Object.keys(CHROMIUM_CHECKSUMS)).toEqual(Object.keys(CHROMIUM_REVISIONS)); + for (let checksum of Object.values(CHROMIUM_CHECKSUMS)) { + expect(checksum).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it('refuses to extract a default revision that does not match its checksum', async () => { + install.chromium.checksums = CHROMIUM_CHECKSUMS; + + await expectAsync(install.chromium()).toBeRejectedWithError( + /^Checksum mismatch for Chromium \d+: expected sha256 [0-9a-f]{64}, got /); + expect(extractZip).not.toHaveBeenCalled(); + }); + + it('does not verify custom revisions', async () => { + install.chromium.checksums = CHROMIUM_CHECKSUMS; + + await install.chromium({ revision: '123456' }); + + expect(extractZip).toHaveBeenCalled(); + }); + for (let [platform, expected] of Object.entries({ linux: { revision: CHROMIUM_REVISIONS.linux, @@ -213,6 +273,7 @@ describe('Unit / Install', () => { // stub the unzip module using esm loader mocks extractZip = jasmine.createSpy('exports').and.resolveTo(); global.__MOCK_IMPORTS__.set('./unzip.js', { default: extractZip }); + mockChromiumChecksums(); // make getters for jasmine property spy let { platform, arch } = process; @@ -418,6 +479,7 @@ describe('Unit / Install in executable', () => { // stub the unzip module using esm loader mocks extractZip = jasmine.createSpy('exports').and.resolveTo(); global.__MOCK_IMPORTS__.set('./unzip.js', { default: extractZip }); + mockChromiumChecksums(); // make getters for jasmine property spy let { platform, arch } = process;