Skip to content
4 changes: 4 additions & 0 deletions packages/cli-doctor/src/utils/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions packages/client/src/trust.js
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions packages/client/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -299,3 +303,11 @@ export {
ProxyHttpsAgent,
proxyAgentFor
} from './proxy.js';

export {
DISTRUSTED_ROOTS,
isTrustedCertificate,
trustedCertificates,
usesOpenSSLStore,
dropDistrustedRoots
} from './trust.js';
Loading
Loading