From fa85bf89842cb233063dd6e630599a008c45609e Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 13:57:03 +0530 Subject: [PATCH 1/7] fix: distrust compromised eMudhra roots and verify Chromium download checksum eMudhra issued 8 intermediates whose private keys are recoverable from the public certificates. Browsers and OS trust stores have blocked them, but Node ships its own roots and still trusts both chains (Node 14, used by the standalone executable, trusts both; Node 20+ still trusts the emSign one). - @percy/client: drop "emSign ECC Root CA - G3" and "AAA Certificate Services" (the Sectigo cross-sign path) from the default CA certificates for every TLS connection in the process. Uses tls.setDefaultCACertificates where available, otherwise defaults secure contexts to the remaining bundled roots plus NODE_EXTRA_CA_CERTS. Skipped when Node uses the OS trust store. - @percy/core: pin a SHA-256 for each platform's default Chromium revision and refuse to extract a downloaded archive that does not match. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/trust.js | 58 +++++++++ packages/client/src/utils.js | 11 ++ packages/client/test/unit/trust.test.js | 150 ++++++++++++++++++++++++ packages/core/src/install.js | 33 +++++- packages/core/test/unit/install.test.js | 62 ++++++++++ 5 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/trust.js create mode 100644 packages/client/test/unit/trust.test.js diff --git a/packages/client/src/trust.js b/packages/client/src/trust.js new file mode 100644 index 000000000..07247b7fb --- /dev/null +++ b/packages/client/src/trust.js @@ -0,0 +1,58 @@ +import fs from 'fs'; +import tls from 'tls'; +import crypto from 'crypto'; + +// 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 +export function isTrustedCertificate(pem) { + let spki = crypto.createPublicKey(pem).export({ type: 'spki', format: 'der' }); + return !DISTRUSTED_ROOTS.has(crypto.createHash('sha256').update(spki).digest('hex')); +} + +// Returns Node's bundled root certificates plus any certificates from NODE_EXTRA_CA_CERTS, which Node +// only adds to its default store and would otherwise be lost when providing an explicit CA list, +// without the distrusted roots +export function trustedCertificates() { + 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); +} + +// 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 OS trust store is used instead of Node's bundled roots, and OS vendors have removed them + let flags = [...process.execArgv, ...(process.env.NODE_OPTIONS ?? '').split(/\s+/)]; + if (flags.some(f => f === '--use-openssl-ca' || f === '--use-system-ca')) 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..9c65b1bac 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,10 @@ export { ProxyHttpsAgent, proxyAgentFor } from './proxy.js'; + +export { + DISTRUSTED_ROOTS, + isTrustedCertificate, + trustedCertificates, + 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..7a02855c5 --- /dev/null +++ b/packages/client/test/unit/trust.test.js @@ -0,0 +1,150 @@ +import fs from 'fs'; +import tls from 'tls'; +import https from 'https'; +import { + request, + DISTRUSTED_ROOTS, + isTrustedCertificate, + trustedCertificates, + 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; + + beforeEach(() => { + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + 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; + }); + + 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('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('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', () => { + 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'); + }); + + if (!canSetDefaultCA) { + describe('on Node versions without setDefaultCACertificates', () => { + let { createSecureContext } = tls; + let { NODE_OPTIONS } = process.env; + + beforeEach(() => { + tls.createSecureContext = jasmine.createSpy('createSecureContext'); + }); + + afterEach(() => { + tls.createSecureContext = createSecureContext; + if (NODE_OPTIONS == null) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = NODE_OPTIONS; + }); + + 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('does nothing when Node uses the OS trust store', () => { + let spy = tls.createSecureContext; + process.env.NODE_OPTIONS = '--max-old-space-size=4096 --use-openssl-ca'; + 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; From 928525b292f50b8f704ba1b526374e509a793a6d Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 14:03:13 +0530 Subject: [PATCH 2/7] test: cover setDefaultCACertificates path and CA fallbacks on every Node version Co-Authored-By: Claude Opus 5.5 --- packages/client/test/unit/trust.test.js | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/client/test/unit/trust.test.js b/packages/client/test/unit/trust.test.js index 7a02855c5..3d60e999d 100644 --- a/packages/client/test/unit/trust.test.js +++ b/packages/client/test/unit/trust.test.js @@ -69,6 +69,11 @@ describe('Unit / Trust', () => { } }); + 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); @@ -109,6 +114,34 @@ describe('Unit / Trust', () => { await expectAsync(get({ ca: ssl.cert })).toBeResolvedTo('ok'); }); + describe('with setDefaultCACertificates', () => { + let { setDefaultCACertificates, getCACertificates } = tls; + + beforeEach(() => { + tls.setDefaultCACertificates = jasmine.createSpy('setDefaultCACertificates'); + tls.getCACertificates = jasmine.createSpy('getCACertificates'); + }); + + afterEach(() => { + Object.assign(tls, { setDefaultCACertificates, getCACertificates }); + }); + + 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; @@ -116,6 +149,7 @@ describe('Unit / Trust', () => { beforeEach(() => { tls.createSecureContext = jasmine.createSpy('createSecureContext'); + delete process.env.NODE_OPTIONS; }); afterEach(() => { From e61341e0710d1abf759c04892eacb736c6dea1aa Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 14:17:44 +0530 Subject: [PATCH 3/7] fix: skip unparseable extra CAs and keep dropping roots with --use-system-ca - isTrustedCertificate treats certificates that cannot be parsed as untrusted (as Node does), so a corrupt block in NODE_EXTRA_CA_CERTS no longer throws while importing @percy/client/utils. - --use-system-ca adds the OS store to Node's bundled roots rather than replacing them, so only --use-openssl-ca skips dropping the roots. - trustedCertificates starts from tls.getCACertificates('default') when available, which keeps system and extra CAs on Node versions that support --use-system-ca but not setDefaultCACertificates. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/trust.js | 33 +++++++++++++++-------- packages/client/test/unit/trust.test.js | 35 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/client/src/trust.js b/packages/client/src/trust.js index 07247b7fb..8b57933b5 100644 --- a/packages/client/src/trust.js +++ b/packages/client/src/trust.js @@ -11,16 +11,25 @@ export const DISTRUSTED_ROOTS = new Map([ ['bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', 'AAA Certificate Services'] ]); -// Returns true when a PEM certificate is not one of the distrusted roots +// 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) { - let spki = crypto.createPublicKey(pem).export({ type: 'spki', format: 'der' }); - return !DISTRUSTED_ROOTS.has(crypto.createHash('sha256').update(spki).digest('hex')); + try { + let spki = crypto.createPublicKey(pem).export({ type: 'spki', format: 'der' }); + return !DISTRUSTED_ROOTS.has(crypto.createHash('sha256').update(spki).digest('hex')); + } catch { + return false; + } } -// Returns Node's bundled root certificates plus any certificates from NODE_EXTRA_CA_CERTS, which Node -// only adds to its default store and would otherwise be lost when providing an explicit CA list, -// without the distrusted roots +// 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 { @@ -34,15 +43,17 @@ export function trustedCertificates() { // 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 OS trust store is used instead of Node's bundled roots, and OS vendors have removed them + // the OpenSSL store replaces Node's bundled roots, and OS vendors have removed them; unlike + // --use-system-ca, which adds the OS store to the bundled roots let flags = [...process.execArgv, ...(process.env.NODE_OPTIONS ?? '').split(/\s+/)]; - if (flags.some(f => f === '--use-openssl-ca' || f === '--use-system-ca')) return; + if (flags.includes('--use-openssl-ca')) 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); + let trusted = trustedCertificates(); + if (trusted.length !== tls.getCACertificates('default').length) { + tls.setDefaultCACertificates(trusted); + } return; } diff --git a/packages/client/test/unit/trust.test.js b/packages/client/test/unit/trust.test.js index 3d60e999d..e6e498853 100644 --- a/packages/client/test/unit/trust.test.js +++ b/packages/client/test/unit/trust.test.js @@ -33,14 +33,26 @@ describe('Unit / Trust', () => { 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', () => { @@ -69,6 +81,21 @@ describe('Unit / Trust', () => { } }); + 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); @@ -171,6 +198,14 @@ describe('Unit / Trust', () => { 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-system-ca'; + dropDistrustedRoots(); + + expect(tls.createSecureContext).not.toBe(spy); + }); + it('does nothing when Node uses the OS trust store', () => { let spy = tls.createSecureContext; process.env.NODE_OPTIONS = '--max-old-space-size=4096 --use-openssl-ca'; From f5c210cb1dce47b47e823dd391d32fef514980ef Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 14:28:04 +0530 Subject: [PATCH 4/7] fix: recognize --use_openssl_ca when deciding whether to drop roots Co-Authored-By: Claude Opus 5.5 --- packages/client/src/trust.js | 3 ++- packages/client/test/unit/trust.test.js | 16 +++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/client/src/trust.js b/packages/client/src/trust.js index 8b57933b5..99c17c4eb 100644 --- a/packages/client/src/trust.js +++ b/packages/client/src/trust.js @@ -46,7 +46,8 @@ export function dropDistrustedRoots() { // the OpenSSL store replaces Node's bundled roots, and OS vendors have removed them; unlike // --use-system-ca, which adds the OS store to the bundled roots let flags = [...process.execArgv, ...(process.env.NODE_OPTIONS ?? '').split(/\s+/)]; - if (flags.includes('--use-openssl-ca')) return; + // Node accepts both dashes and underscores in option names + if (flags.some(f => f.replace(/_/g, '-') === '--use-openssl-ca')) return; // newer versions of Node can replace the default CA certificates directly if (tls.setDefaultCACertificates && tls.getCACertificates) { diff --git a/packages/client/test/unit/trust.test.js b/packages/client/test/unit/trust.test.js index e6e498853..3876aff90 100644 --- a/packages/client/test/unit/trust.test.js +++ b/packages/client/test/unit/trust.test.js @@ -206,13 +206,15 @@ describe('Unit / Trust', () => { expect(tls.createSecureContext).not.toBe(spy); }); - it('does nothing when Node uses the OS trust store', () => { - let spy = tls.createSecureContext; - process.env.NODE_OPTIONS = '--max-old-space-size=4096 --use-openssl-ca'; - dropDistrustedRoots(); - - expect(tls.createSecureContext).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); + }); + } }); } }); From f89979cf9239ee91e15b4abf4a8b0cb5128f4366 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 14:51:26 +0530 Subject: [PATCH 5/7] fix: drop distrusted roots in percy doctor and respect OpenSSL-store builds - cli-doctor's prober sends PERCY_TOKEN through its own https/tls client before anything else may import @percy/client/utils, so drop the roots there explicitly. - Skip when Node verifies against the OpenSSL store: distro builds (Debian, Ubuntu) default to it without a runtime flag, and replacing it with the bundled roots would drop OS-installed corporate CAs. --use-openssl-ca and --use-bundled-ca follow Node's precedence (last wins, command line over NODE_OPTIONS). - Read the default CA certificates once, and debug-log skipped certificates. Co-Authored-By: Claude Opus 5.5 --- packages/cli-doctor/src/utils/http.js | 4 +++ packages/client/src/trust.js | 36 +++++++++++++------ packages/client/src/utils.js | 1 + packages/client/test/unit/trust.test.js | 47 +++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) 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 index 99c17c4eb..d369a586b 100644 --- a/packages/client/src/trust.js +++ b/packages/client/src/trust.js @@ -1,6 +1,7 @@ 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 @@ -17,7 +18,8 @@ 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 { + } catch (error) { + logger('client:trust').debug(`Skipping a CA certificate that cannot be parsed: ${error.message}`); return false; } } @@ -40,21 +42,35 @@ export function trustedCertificates() { 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 replaces Node's bundled roots, and OS vendors have removed them; unlike - // --use-system-ca, which adds the OS store to the bundled roots - let flags = [...process.execArgv, ...(process.env.NODE_OPTIONS ?? '').split(/\s+/)]; - // Node accepts both dashes and underscores in option names - if (flags.some(f => f.replace(/_/g, '-') === '--use-openssl-ca')) return; + // 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 trusted = trustedCertificates(); - if (trusted.length !== tls.getCACertificates('default').length) { - tls.setDefaultCACertificates(trusted); - } + let current = tls.getCACertificates('default'); + let trusted = current.filter(isTrustedCertificate); + if (trusted.length !== current.length) tls.setDefaultCACertificates(trusted); return; } diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 9c65b1bac..1735d8f0a 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -308,5 +308,6 @@ 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 index 3876aff90..9d9e8ab1d 100644 --- a/packages/client/test/unit/trust.test.js +++ b/packages/client/test/unit/trust.test.js @@ -6,6 +6,7 @@ import { DISTRUSTED_ROOTS, isTrustedCertificate, trustedCertificates, + usesOpenSSLStore, dropDistrustedRoots } from '@percy/client/utils'; @@ -107,6 +108,52 @@ describe('Unit / Trust', () => { }); }); + 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(false); + 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; From d97da5fe06d0f9330a2dd8118bef6726f59890d3 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 15:21:22 +0530 Subject: [PATCH 6/7] test: make trust specs independent of how Node was built Co-Authored-By: Claude Opus 5.5 --- packages/client/test/unit/trust.test.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/client/test/unit/trust.test.js b/packages/client/test/unit/trust.test.js index 9d9e8ab1d..10f7ab101 100644 --- a/packages/client/test/unit/trust.test.js +++ b/packages/client/test/unit/trust.test.js @@ -124,7 +124,7 @@ describe('Unit / Trust', () => { }); it('follows how Node was built by default', () => { - expect(usesOpenSSLStore()).toBe(false); + expect(usesOpenSSLStore()).toBe(!!process.config.variables.node_use_openssl_ca); expect(usesOpenSSLStore({})).toBe(false); expect(usesOpenSSLStore({ node_use_openssl_ca: 1 })).toBe(true); }); @@ -171,7 +171,10 @@ describe('Unit / Trust', () => { }); it('removes the distrusted roots when the client utils are imported', () => { - if (canSetDefaultCA) { + // 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); @@ -190,14 +193,19 @@ describe('Unit / Trust', () => { describe('with setDefaultCACertificates', () => { let { setDefaultCACertificates, getCACertificates } = tls; + let { NODE_OPTIONS } = process.env; beforeEach(() => { + // use the bundled roots regardless of how Node was built + process.env.NODE_OPTIONS = '--use-bundled-ca'; 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; }); it('replaces the default CA certificates without the distrusted roots', () => { @@ -223,7 +231,8 @@ describe('Unit / Trust', () => { beforeEach(() => { tls.createSecureContext = jasmine.createSpy('createSecureContext'); - delete process.env.NODE_OPTIONS; + // use the bundled roots regardless of how Node was built + process.env.NODE_OPTIONS = '--use-bundled-ca'; }); afterEach(() => { @@ -247,7 +256,7 @@ describe('Unit / Trust', () => { it('still drops the distrusted roots when Node adds the system store', () => { let spy = tls.createSecureContext; - process.env.NODE_OPTIONS = '--use-system-ca'; + process.env.NODE_OPTIONS = '--use-bundled-ca --use-system-ca'; dropDistrustedRoots(); expect(tls.createSecureContext).not.toBe(spy); From 791345dd6f9e6cf04f7a4bfbabb2cb613ad94acf Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Wed, 21 Oct 2026 15:30:50 +0530 Subject: [PATCH 7/7] test: also clear execArgv when pinning the bundled store in trust specs Co-Authored-By: Claude Opus 5.5 --- packages/client/test/unit/trust.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/test/unit/trust.test.js b/packages/client/test/unit/trust.test.js index 10f7ab101..301b2a00f 100644 --- a/packages/client/test/unit/trust.test.js +++ b/packages/client/test/unit/trust.test.js @@ -194,10 +194,12 @@ describe('Unit / Trust', () => { 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 + // 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'); }); @@ -206,6 +208,7 @@ describe('Unit / Trust', () => { 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', () => { @@ -228,17 +231,20 @@ describe('Unit / Trust', () => { 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 + // 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', () => {