Skip to content

SDK-7999 Reject TLS chains carrying the compromised eMudhra intermediate keys - #188

Draft
harshit-browserstack wants to merge 4 commits into
browserstack:masterfrom
harshit-browserstack:fix/SDK-7999-emudhra-key-block
Draft

harshit-browserstack wants to merge 4 commits into
browserstack:masterfrom
harshit-browserstack:fix/SDK-7999-emudhra-key-block

Conversation

@harshit-browserstack

@harshit-browserstack harshit-browserstack commented Oct 21, 2026 •

Copy link
Copy Markdown

Jira: SDK-7999. Same fix as browserstack-binary#1960, browserstack-node-agent#2742 and browserstack-cypress-cli#1203.

Problem

The private keys of 8 eMudhra intermediate CAs are recoverable (incident), and Node.js has not shipped the distrust yet (nodejs/node#66871 is unreleased). The binary-endpoint lookup (/binary/api/v1/endpoint, which carries the access key) and the binary download both accept a certificate issued under them, so anyone on the network path can read the key or serve their own BrowserStackLocal, which this library then runs.

Change

  • New lib/distrustedCaCheck.js wraps tls.checkServerIdentity: Node's hostname check runs first, then the connection is refused (ERR_TLS_DISTRUSTED_INTERMEDIATE) if any certificate in the chain has one of the 8 published SPKI SHA-256 hashes. Matching the key catches both the emSign-issued and the Sectigo cross-signed copies.
  • Installed in lib/Local.js and in lib/fetchDownloadSourceUrl.js / lib/download.js, because startSync runs those two as separate node processes that never load Local.js.
  • The SPKI is re-exported with crypto.createPublicKey from the certificate (cert.pubkey is the raw point for EC keys, and X509Certificate needs Node 15.6). On Node older than 11.6, where createPublicKey does not exist, the check is skipped.
  • Every copy of the module in a process shares one hash set and one wrapper, so it composes with the same check in the node SDK.

Tests

  • bs-host pointed at dv-g3b-emudhra.we-have-a-problem.com: 1.5.16 completes TLS and receives the host's reply; with this change the endpoint lookup fails with ERR_TLS_DISTRUSTED_INTERMEDIATE in both startSync (child process) and start, on Node 14 and 22.
  • Real downloads and tunnels still work: the node SDK downloaded BrowserStackLocal through startSync with this change, and Automate sessions through the tunnel passed.
  • npm test: 47 passing, 2 pending, 4 failing; the same 4 fail on master (the download tests build LocalBinary without an access key).

Node still trusts the 8 eMudhra intermediates whose private keys are recoverable, so the endpoint lookup and the binary download could be served by anyone holding a certificate issued under them. Install a tls.checkServerIdentity wrapper in Local.js and in the two scripts startSync runs as child processes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited)
  • Review profile: ASSERTIVE
  • Plan: Enterprise
  • Run ID: 94a27b90-d07a-4c4f-bd8c-64800db5193c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

…CA names (SDK-7999)

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 <noreply@anthropic.com>
@harshit-browserstack

Copy link
Copy Markdown
Author

Review follow-up — two gaps in the chain check, fixed in the latest commit (same change in browserstack-binary#1960, browserstack-node-agent#2742, browserstack-cypress-cli#1203, browserstack-local-nodejs#188):

  1. One unparseable certificate ended the check for the whole chain (fail-open). The catch is now per certificate: that certificate is skipped and its issuers are still checked.
  2. Decoy bypass. Node links issuerCertificate by name, so a server sending [leaf, expired decoy with the intermediate's name and key id, real intermediate] passes OpenSSL verification (it skips the expired decoy) while Node's links lead the walk to the decoy and never reach the distrusted key. Reproduced locally on Node 18 and 22. The check now also matches the 8 intermediates' subject names against each certificate's subject and issuer; they are all pathlen:0, so the leaf's issuer is always one of those names and a decoy cannot divert that.

Verified locally: the decoy chain is now refused in both orders on Node 16/18/22 (20/20), the earlier cases still hold (50/50, including a forced parse failure on the leaf), the 8 listed names match the parsed subjects of all 16 published certificates, and 20 hosts the SDK, binary and Local use still connect.

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 <noreply@anthropic.com>
@harshit-browserstack

Copy link
Copy Markdown
Author

Security-review follow-up (latest commit): names are now compared the way OpenSSL chains them (case-insensitive, whitespace collapsed). Reproduced the gap first: a leaf naming its issuer test intermediate validates through the real Test Intermediate, and with a TEST INTERMEDIATE decoy diverting Node's issuer links, exact matching let the chain through on the previous commit. Now refused on Node 16/18/22; full matrix 80/80 decoy runs + 50/50 standard cases, 16/16 published certificates still matched, 20/20 legitimate hosts still connect.

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 <noreply@anthropic.com>
@harshit-browserstack

Copy link
Copy Markdown
Author

Re-review follow-up (latest commit): the install state now lives on the tls module instead of global. Reproduced first: Jest gives each test file its own global but shares core modules, so three Jest-style contexts wrapped tls.checkServerIdentity three times; now once (Node 16/18/22). Worker threads still install their own check and refuse the incident host. Regression unchanged: 80/80 decoy runs, 50/50 standard cases, legitimate hosts still connect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant