From 7ecfe63b71f9eab6f6c6491a95aed1f548aecae5 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 17:27:50 +0530 Subject: [PATCH 1/5] fix(tls): reject chains carrying the compromised eMudhra intermediate keys (SDK-8000) Node still trusts the 8 eMudhra intermediates whose private keys are recoverable. Install a tls.checkServerIdentity wrapper from bin/helpers/utils.js, which the CLI and the reporter/plugins that run inside the customer's Cypress all load, and reject any chain containing one of the 8 SPKI SHA-256 hashes. Co-Authored-By: Claude Opus 5.5 --- bin/helpers/distrustedCaCheck.js | 86 ++++++++++++++++++++++++++++++++ bin/helpers/utils.js | 3 ++ 2 files changed, 89 insertions(+) create mode 100644 bin/helpers/distrustedCaCheck.js diff --git a/bin/helpers/distrustedCaCheck.js b/bin/helpers/distrustedCaCheck.js new file mode 100644 index 00000000..e013f538 --- /dev/null +++ b/bin/helpers/distrustedCaCheck.js @@ -0,0 +1,86 @@ +'use strict'; + +/* + * The private keys of 8 eMudhra intermediate CAs are recoverable, so anyone can issue certificates + * under them. Browsers and OS trust stores block them, but Node's bundled roots still accept their + * chains. Reject any TLS chain carrying one of those keys, matched by SPKI SHA-256 so the emSign-issued + * and the Sectigo cross-signed copies are both caught. + * Source: https://emudhra.we-have-a-problem.com/impacted-intermediates.json + */ + +const tls = require('tls'); +const crypto = require('crypto'); + +const STATE_KEY = Symbol.for('browserstack.distrustedCaCheck'); + +const DISTRUSTED_SPKI_SHA256 = [ + 'EBED6475C2C2D80FCB199F3FE8DD6E6B3CE60B77EB15D4E42C3DEC0C6F5B5105', // EM DV TLS CA - G3B + '43BFFA19D6EF651451F6841E019931E96F6F2EAEFF28AB4B6F1CE4576DE2717B', // EM OV TLS CA - G3B + 'E0254C7FCDC265E2F0F8A428DABB75AB4AE039B4872D62F5EE57F0877D5A8A41', // EM EV TLS CA - G3B + '49DD353DC2B919A3770645117D0E60C16115E7D1BC259EEFE734B00EA434FE5F', // EM DV TLS CA - G3C + '63ECEFEE431D49523BC1136C4E72D0206A477086CAF606CD5CF025686C109CF8', // EM OV TLS CA - G3C + '5C0660A2B164910D8FF589C18E14552E9E0EC237DCEFE2CB736D41A4811F81A0', // EM EV TLS CA - G3C + 'EC4FFA1AD0AC88081314B7AAFFEF6A91F97CD57291BB5BA686C5415F5EB4F6B6', // EM OV CS CA - G3B + '92C063D67E8F14DB71D288E11266FFDAF69CBF115F7BAB14F3BC2F68D25E0E30' // EM OV CS CA - G3C +]; + +// The peer certificate's `pubkey` is the raw point for EC keys, not the SPKI, so re-export the SPKI from +// the certificate. createPublicKey rather than X509Certificate, which needs Node 15.6. +function spkiSha256(rawCert) { + const pem = `-----BEGIN CERTIFICATE-----\n${rawCert.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`; + const spki = crypto.createPublicKey(pem).export({type: 'spki', format: 'der'}); + + return crypto.createHash('sha256').update(spki).digest('hex').toUpperCase(); +} + +function findDistrustedCert(cert, distrusted) { + for (let current = cert, previous; current && current !== previous; previous = current, current = current.issuerCertificate) { + if (current.raw && distrusted.has(spkiSha256(current.raw))) { + return current; + } + } + + return null; +} + +// Every copy of this module in the process shares one hash set and one wrapper. +function installDistrustedCaCheck() { + if (typeof crypto.createPublicKey !== 'function') { + return false; + } + const existing = global[STATE_KEY]; + if (existing) { + DISTRUSTED_SPKI_SHA256.forEach((hash) => existing.distrusted.add(hash)); + + return false; + } + const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256)}; + global[STATE_KEY] = state; + + const originalCheckServerIdentity = tls.checkServerIdentity; + tls.checkServerIdentity = function checkServerIdentity(hostname, cert) { + const identityError = originalCheckServerIdentity(hostname, cert); + if (identityError) { + return identityError; + } + let distrustedCert; + try { + distrustedCert = findDistrustedCert(cert, state.distrusted); + } catch (err) { + // A key type createPublicKey cannot parse is not one of the distrusted EC keys. + return undefined; + } + if (!distrustedCert) { + return undefined; + } + const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedCert.subject && distrustedCert.subject.CN}"`); + error.code = 'ERR_TLS_DISTRUSTED_INTERMEDIATE'; + error.host = hostname; + + return error; + }; + + return true; +} + +module.exports = {installDistrustedCaCheck}; diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index 2cc7f133..d3a4f472 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -1,4 +1,7 @@ "use strict"; +// Installed here rather than in runner.js: the CLI and the reporter/plugins that run inside the customer's Cypress all load this file. +require('./distrustedCaCheck').installDistrustedCaCheck(); + const os = require("os"); const path = require("path"); const fs = require("fs"); From 52846d78ed8ba9806dbddc148d2df412a28cef3f Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 20:19:54 +0530 Subject: [PATCH 2/5] fix(tls): check each certificate separately and match the distrusted CA names (SDK-8000) Two gaps from review: a parse failure on one certificate ended the walk for the whole chain, and Node links issuerCertificate by name, so a decoy certificate sent ahead of the real intermediate diverted the walk past the distrusted key. Skip only the certificate that fails to parse, and also match the 8 intermediates' subject names against each certificate's subject and issuer (they are pathlen:0, so the leaf's issuer is always one of them). Co-Authored-By: Claude Opus 5.5 --- bin/helpers/distrustedCaCheck.js | 59 +++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/bin/helpers/distrustedCaCheck.js b/bin/helpers/distrustedCaCheck.js index e013f538..6e4c0f1c 100644 --- a/bin/helpers/distrustedCaCheck.js +++ b/bin/helpers/distrustedCaCheck.js @@ -3,8 +3,8 @@ /* * The private keys of 8 eMudhra intermediate CAs are recoverable, so anyone can issue certificates * under them. Browsers and OS trust stores block them, but Node's bundled roots still accept their - * chains. Reject any TLS chain carrying one of those keys, matched by SPKI SHA-256 so the emSign-issued - * and the Sectigo cross-signed copies are both caught. + * chains. Reject any TLS chain that carries one of those keys (SPKI SHA-256, which catches both the + * emSign-issued and the Sectigo cross-signed copies) or one of their names. * Source: https://emudhra.we-have-a-problem.com/impacted-intermediates.json */ @@ -24,6 +24,24 @@ const DISTRUSTED_SPKI_SHA256 = [ '92C063D67E8F14DB71D288E11266FFDAF69CBF115F7BAB14F3BC2F68D25E0E30' // EM OV CS CA - G3C ]; +// Node links issuerCertificate by name, so a decoy certificate sent ahead of the real intermediate can divert +// the walk away from the distrusted key. The 8 intermediates are pathlen:0, so the leaf's issuer is always +// one of their names; matching names closes that gap. +const DISTRUSTED_NAMES = [ + 'EM DV TLS CA - G3B|eMudhra BV|NL', + 'EM OV TLS CA - G3B|eMudhra BV|NL', + 'EM EV TLS CA - G3B|eMudhra BV|NL', + 'EM DV TLS CA - G3C|eMudhra Inc|US', + 'EM OV TLS CA - G3C|eMudhra Inc|US', + 'EM EV TLS CA - G3C|eMudhra Inc|US', + 'EM OV CS CA - G3B|eMudhra BV|NL', + 'EM OV CS CA - G3C|eMudhra Inc|US' +]; + +function nameKey(name) { + return name ? `${name.CN || ''}|${name.O || ''}|${name.C || ''}` : ''; +} + // The peer certificate's `pubkey` is the raw point for EC keys, not the SPKI, so re-export the SPKI from // the certificate. createPublicKey rather than X509Certificate, which needs Node 15.6. function spkiSha256(rawCert) { @@ -33,17 +51,31 @@ function spkiSha256(rawCert) { return crypto.createHash('sha256').update(spki).digest('hex').toUpperCase(); } -function findDistrustedCert(cert, distrusted) { +// Returns the name of the distrusted intermediate found in the chain, or null. +function findDistrustedIntermediate(cert, state) { for (let current = cert, previous; current && current !== previous; previous = current, current = current.issuerCertificate) { - if (current.raw && distrusted.has(spkiSha256(current.raw))) { - return current; + if (state.distrustedNames.has(nameKey(current.subject))) { + return current.subject.CN; + } + if (state.distrustedNames.has(nameKey(current.issuer))) { + return current.issuer.CN; + } + let hash; + try { + hash = current.raw && spkiSha256(current.raw); + } catch (err) { + // A key createPublicKey cannot parse is not one of the distrusted EC keys; keep checking its issuers. + continue; + } + if (hash && state.distrusted.has(hash)) { + return current.subject && current.subject.CN; } } return null; } -// Every copy of this module in the process shares one hash set and one wrapper. +// Every copy of this module in the process shares one state and one wrapper. function installDistrustedCaCheck() { if (typeof crypto.createPublicKey !== 'function') { return false; @@ -51,10 +83,11 @@ function installDistrustedCaCheck() { const existing = global[STATE_KEY]; if (existing) { DISTRUSTED_SPKI_SHA256.forEach((hash) => existing.distrusted.add(hash)); + DISTRUSTED_NAMES.forEach((name) => existing.distrustedNames.add(name)); return false; } - const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256)}; + const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256), distrustedNames: new Set(DISTRUSTED_NAMES)}; global[STATE_KEY] = state; const originalCheckServerIdentity = tls.checkServerIdentity; @@ -63,17 +96,11 @@ function installDistrustedCaCheck() { if (identityError) { return identityError; } - let distrustedCert; - try { - distrustedCert = findDistrustedCert(cert, state.distrusted); - } catch (err) { - // A key type createPublicKey cannot parse is not one of the distrusted EC keys. - return undefined; - } - if (!distrustedCert) { + const distrustedName = findDistrustedIntermediate(cert, state); + if (!distrustedName) { return undefined; } - const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedCert.subject && distrustedCert.subject.CN}"`); + const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedName}"`); error.code = 'ERR_TLS_DISTRUSTED_INTERMEDIATE'; error.host = hostname; From 97bd448230f10b43a4dbb7ccc06c37092097e0f3 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 20:25:04 +0530 Subject: [PATCH 3/5] fix(tls): compare distrusted CA names the way OpenSSL does (SDK-8000) OpenSSL chains names case-insensitively with whitespace collapsed, so a leaf naming its issuer 'em dv tls ca - g3b' still validates through the real intermediate; with a differently-cased decoy diverting the issuer walk, exact matching missed both names. Normalize names the same way before comparing, and never report an empty name when a key matches. Co-Authored-By: Claude Opus 5.5 --- bin/helpers/distrustedCaCheck.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bin/helpers/distrustedCaCheck.js b/bin/helpers/distrustedCaCheck.js index 6e4c0f1c..af1d06bf 100644 --- a/bin/helpers/distrustedCaCheck.js +++ b/bin/helpers/distrustedCaCheck.js @@ -36,10 +36,15 @@ const DISTRUSTED_NAMES = [ 'EM EV TLS CA - G3C|eMudhra Inc|US', 'EM OV CS CA - G3B|eMudhra BV|NL', 'EM OV CS CA - G3C|eMudhra Inc|US' -]; +].map((name) => name.toLowerCase()); + +// OpenSSL chains names case-insensitively with whitespace collapsed, so compare them the same way. +function normalize(value) { + return String((Array.isArray(value) ? value.join(',') : value) || '').trim().replace(/\s+/g, ' ').toLowerCase(); +} function nameKey(name) { - return name ? `${name.CN || ''}|${name.O || ''}|${name.C || ''}` : ''; + return name ? `${normalize(name.CN)}|${normalize(name.O)}|${normalize(name.C)}` : ''; } // The peer certificate's `pubkey` is the raw point for EC keys, not the SPKI, so re-export the SPKI from @@ -68,7 +73,7 @@ function findDistrustedIntermediate(cert, state) { continue; } if (hash && state.distrusted.has(hash)) { - return current.subject && current.subject.CN; + return (current.subject && current.subject.CN) || 'unknown'; } } From 2603ad75ba4fc93e2a6b1d2fed41b91131742515 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 20:46:43 +0530 Subject: [PATCH 4/5] fix(tls): keep the install state on the tls module (SDK-8000) Jest runs each test file in its own context with its own global but shares core modules, so the SDK's per-file setup re-ran the install and wrapped tls.checkServerIdentity again for every test file. Keep the install state on the tls module instead; worker threads still get their own tls module and install their own check. Co-Authored-By: Claude Opus 5.5 --- bin/helpers/distrustedCaCheck.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/helpers/distrustedCaCheck.js b/bin/helpers/distrustedCaCheck.js index af1d06bf..31009b0b 100644 --- a/bin/helpers/distrustedCaCheck.js +++ b/bin/helpers/distrustedCaCheck.js @@ -80,12 +80,13 @@ function findDistrustedIntermediate(cert, state) { return null; } -// Every copy of this module in the process shares one state and one wrapper. +// Every copy of this module in the process shares one state and one wrapper. The state lives on the tls module, +// not on global: Jest gives each test file its own global but shares core modules across them. function installDistrustedCaCheck() { if (typeof crypto.createPublicKey !== 'function') { return false; } - const existing = global[STATE_KEY]; + const existing = tls[STATE_KEY]; if (existing) { DISTRUSTED_SPKI_SHA256.forEach((hash) => existing.distrusted.add(hash)); DISTRUSTED_NAMES.forEach((name) => existing.distrustedNames.add(name)); @@ -93,7 +94,7 @@ function installDistrustedCaCheck() { return false; } const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256), distrustedNames: new Set(DISTRUSTED_NAMES)}; - global[STATE_KEY] = state; + tls[STATE_KEY] = state; const originalCheckServerIdentity = tls.checkServerIdentity; tls.checkServerIdentity = function checkServerIdentity(hostname, cert) { From 96b3a51e2cd1684708fe2e4db94310e4712da86b Mon Sep 17 00:00:00 2001 From: Harshit Date: Thu, 22 Oct 2026 13:31:09 +0530 Subject: [PATCH 5/5] fix(tls): guard the distrusted CA check install and add unit tests (SDK-8000) utils.js also loads inside the customer's Cypress process, so an install that threw would stop it loading. Log at debug and carry on instead. Add unit tests for the name, normalised-name and key matches, a clean chain, the hostname error taking precedence, and a second install not wrapping again. Co-Authored-By: Claude Opus 5.5 --- bin/helpers/utils.js | 6 +- test/unit/bin/helpers/distrustedCaCheck.js | 95 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 test/unit/bin/helpers/distrustedCaCheck.js diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index d3a4f472..a6903720 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -1,6 +1,10 @@ "use strict"; // Installed here rather than in runner.js: the CLI and the reporter/plugins that run inside the customer's Cypress all load this file. -require('./distrustedCaCheck').installDistrustedCaCheck(); +try { + require('./distrustedCaCheck').installDistrustedCaCheck(); +} catch (err) { + require('./logger').winstonLogger.debug(`Distrusted CA check failed to install: ${err && err.message}`); +} const os = require("os"); const path = require("path"); diff --git a/test/unit/bin/helpers/distrustedCaCheck.js b/test/unit/bin/helpers/distrustedCaCheck.js new file mode 100644 index 00000000..f285689e --- /dev/null +++ b/test/unit/bin/helpers/distrustedCaCheck.js @@ -0,0 +1,95 @@ +'use strict'; +const chai = require("chai"), + expect = chai.expect, + tls = require('tls'); + +const { installDistrustedCaCheck } = require("../../../../bin/helpers/distrustedCaCheck"); + +// EM DV TLS CA - G3B as issued by emSign Root TLS CA - G3, from https://emudhra.we-have-a-problem.com/impacted-intermediates.json +const DISTRUSTED_INTERMEDIATE_PEM = `-----BEGIN CERTIFICATE----- +MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw +CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1 +NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11 +ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J +hZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p +1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L +WUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow +KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB +BAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY +aHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw +b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f +BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j +cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E +AwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO +Ibh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8 +gt2TlfJbHii+KNvK +-----END CERTIFICATE-----`; + +const NOT_A_CERTIFICATE = Buffer.from('not a certificate'); + +function peerChain(intermediate, leafIssuer = intermediate.subject) { + const root = { subject: { CN: 'Test Root', O: 'Test', C: 'US' }, issuer: { CN: 'Test Root', O: 'Test', C: 'US' }, raw: NOT_A_CERTIFICATE }; + root.issuerCertificate = root; + intermediate.issuer = root.subject; + intermediate.issuerCertificate = root; + return { + subject: { CN: 'example.com' }, + subjectaltname: 'DNS:example.com', + issuer: leafIssuer, + raw: NOT_A_CERTIFICATE, + issuerCertificate: intermediate, + }; +} + +describe("distrustedCaCheck", () => { + before(() => { + installDistrustedCaCheck(); + }); + + it("refuses a chain carrying a distrusted intermediate's name", () => { + const cert = peerChain({ subject: { CN: 'EM DV TLS CA - G3B', O: 'eMudhra BV', C: 'NL' }, raw: NOT_A_CERTIFICATE }); + + const error = tls.checkServerIdentity('example.com', cert); + + expect(error).to.be.an('error'); + expect(error.code).to.equal('ERR_TLS_DISTRUSTED_INTERMEDIATE'); + }); + + it("compares names case-insensitively with whitespace collapsed", () => { + const cert = peerChain( + { subject: { CN: 'Test Intermediate', O: 'Test', C: 'US' }, raw: NOT_A_CERTIFICATE }, + { CN: ' em dv tls ca - g3b', O: 'EMUDHRA BV', C: 'nl' } + ); + + expect(tls.checkServerIdentity('example.com', cert).code).to.equal('ERR_TLS_DISTRUSTED_INTERMEDIATE'); + }); + + it("refuses a chain carrying a distrusted key under another name", () => { + const raw = Buffer.from(DISTRUSTED_INTERMEDIATE_PEM.replace(/-----[^-]+-----|\s/g, ''), 'base64'); + const cert = peerChain({ subject: { CN: 'Test Intermediate', O: 'Test', C: 'US' }, raw }); + + expect(tls.checkServerIdentity('example.com', cert).code).to.equal('ERR_TLS_DISTRUSTED_INTERMEDIATE'); + }); + + it("allows a chain without a distrusted intermediate", () => { + const cert = peerChain({ subject: { CN: 'Test Intermediate', O: 'Test', C: 'US' }, raw: NOT_A_CERTIFICATE }); + + expect(tls.checkServerIdentity('example.com', cert)).to.be.undefined; + }); + + it("returns the hostname error before checking the chain", () => { + const cert = peerChain({ subject: { CN: 'EM DV TLS CA - G3B', O: 'eMudhra BV', C: 'NL' }, raw: NOT_A_CERTIFICATE }); + + expect(tls.checkServerIdentity('other.com', cert).code).to.equal('ERR_TLS_CERT_ALTNAME_INVALID'); + }); + + it("does not wrap tls.checkServerIdentity a second time", () => { + const wrapped = tls.checkServerIdentity; + + expect(installDistrustedCaCheck()).to.equal(false); + expect(tls.checkServerIdentity).to.equal(wrapped); + }); +});