Commit a69953c0c11 for nodejs

commit a69953c0c11ac9fd4bc860a529a87747bb130713
Author: Matteo Collina <hello@matteocollina.com>
Date:   Tue Oct 27 16:48:25 2026 +0100

    tls: apply the CA of SNI contexts without a certificate

    `CertCbDone()` only installed an SNI context's CA store and client CA
    list when `setSniContext()` succeeded, which requires the context to
    have a certificate. A context added with `server.addContext(host,
    { ca })` was therefore ignored for client certificate verification,
    and clients for that host were verified against the default context's
    CA instead.

    Apply the SNI context's CA whenever it configured one through
    `addCACert()`, even without a certificate. A context with neither a
    certificate nor its own CA still keeps the default trust store, rather
    than switching to the bundled root certificates. Also report a failure
    of `setSniContext()` instead of silently continuing with the default
    credentials.

    Refs: https://hackerone.com/reports/4103553
    Signed-off-by: Matteo Collina <hello@matteocollina.com>
    PR-URL: https://github.com/nodejs-private/node-private/pull/1020
    Reviewed-By: Tim Perry <pimterry@gmail.com>
    CVE-ID: CVE-2026-123867

diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc
index 7c3f9cbdf8d..f28ce556df3 100644
--- a/src/crypto/crypto_context.cc
+++ b/src/crypto/crypto_context.cc
@@ -1675,6 +1675,7 @@ inline void SecureContext::Reset() {
   ctx_.reset();
   cert_.reset();
   issuer_.reset();
+  has_own_ca_ = false;
 }

 SecureContext::~SecureContext() {
@@ -1964,6 +1965,7 @@ void SecureContext::SetAllowPartialTrustChain(
 void SecureContext::SetCACert(const BIOPointer& bio) {
   ClearErrorOnReturn clear_error_on_return;
   AddCACertificates(env(), ctx_.get(), bio, &own_cert_store_cache_);
+  has_own_ca_ = true;
 }

 void SecureContext::AddCACert(const FunctionCallbackInfo<Value>& args) {
diff --git a/src/crypto/crypto_context.h b/src/crypto/crypto_context.h
index 1fecf228dec..a762678ea2d 100644
--- a/src/crypto/crypto_context.h
+++ b/src/crypto/crypto_context.h
@@ -52,6 +52,9 @@ class SecureContext final : public BaseObject {
   static SecureContext* Create(Environment* env);

   const ncrypto::SSLCtxPointer& ctx() const { return ctx_; }
+  // True if CA certificates were added with addCACert(), as opposed to the
+  // context relying on the default root store.
+  bool has_own_ca() const { return has_own_ca_; }

   // Non-const ctx() that allows for non-default initialization of
   // the SecureContext.
@@ -188,6 +191,7 @@ class SecureContext final : public BaseObject {
   ncrypto::X509Pointer issuer_;
   // Non-owning cache for SSL_CTX_get_cert_store(ctx_.get())
   X509_STORE* own_cert_store_cache_ = nullptr;
+  bool has_own_ca_ = false;

   unsigned char ticket_key_name_[16];
   unsigned char ticket_key_aes_[16];
diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc
index 8c2dad08ad6..333da5d7e72 100644
--- a/src/crypto/crypto_tls.cc
+++ b/src/crypto/crypto_tls.cc
@@ -1776,11 +1776,19 @@ void TLSWrap::CertCbDone(const FunctionCallbackInfo<Value>& args) {
     // Store the SNI context for later use.
     w->sni_context_ = BaseObjectPtr<SecureContext>(sc);

-    if (w->ssl_.setSniContext(w->sni_context_->ctx()) && !w->SetCACerts(sc)) {
-      // Not clear why sometimes we throw error, and sometimes we call
-      // onerror(). Both cause .destroy(), but onerror does a bit more.
-      unsigned long err = ERR_get_error();  // NOLINT(runtime/int)
-      return ThrowCryptoError(env, err, "CertCbDone");
+    // An SNI context without a certificate keeps the default context's
+    // certificate, but a `ca` it configured must still replace the default
+    // trust store for client certificates. A context with neither keeps the
+    // default context's trust store.
+    const bool has_cert = SSL_CTX_get0_certificate(sc->ctx().get()) != nullptr;
+    if (has_cert || sc->has_own_ca()) {
+      if ((has_cert && !w->ssl_.setSniContext(w->sni_context_->ctx())) ||
+          !w->SetCACerts(sc)) {
+        // Not clear why sometimes we throw error, and sometimes we call
+        // onerror(). Both cause .destroy(), but onerror does a bit more.
+        unsigned long err = ERR_get_error();  // NOLINT(runtime/int)
+        return ThrowCryptoError(env, err, "CertCbDone");
+      }
     }
     // setSniContext copies the cert via SSL_use_certificate which does not
     // carry over pre-compressed certificate data (comp_cert). If the new
diff --git a/test/parallel/test-tls-sni-context-ca-only.js b/test/parallel/test-tls-sni-context-ca-only.js
new file mode 100644
index 00000000000..e9e5bce7301
--- /dev/null
+++ b/test/parallel/test-tls-sni-context-ca-only.js
@@ -0,0 +1,88 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+// An SNI context that sets `ca` but no certificate must still replace the
+// default trust store for client certificates on that host. A context that
+// sets neither keeps the default trust store.
+
+const assert = require('assert');
+const tls = require('tls');
+const fixtures = require('../common/fixtures');
+
+const serverCreds = {
+  key: fixtures.readKey('agent2-key.pem'),
+  cert: fixtures.readKey('agent2-cert.pem'),
+};
+const ca1 = fixtures.readKey('ca1-cert.pem');
+const ca2 = fixtures.readKey('ca2-cert.pem');
+const clients = {
+  agent1: {  // Issued by CA1.
+    key: fixtures.readKey('agent1-key.pem'),
+    cert: fixtures.readKey('agent1-cert.pem'),
+  },
+  agent3: {  // Issued by CA2.
+    key: fixtures.readKey('agent3-key.pem'),
+    cert: fixtures.readKey('agent3-cert.pem'),
+  },
+};
+
+const cases = [
+  { name: 'ca only', context: { ca: ca2 }, accepted: 'agent3' },
+  { name: 'cert, key and ca', context: { ...serverCreds, ca: ca2 },
+    accepted: 'agent3' },
+  { name: 'no ca or cert', context: { ciphers: 'DEFAULT' },
+    accepted: 'agent1' },
+];
+
+function connect(port, client, version) {
+  return new Promise((resolve) => {
+    const socket = tls.connect({
+      port,
+      servername: 'protected.example',
+      ...clients[client],
+      rejectUnauthorized: false,
+      minVersion: version,
+      maxVersion: version,
+    });
+    let data = '';
+    socket.setEncoding('utf8');
+    socket.on('data', (chunk) => data += chunk);
+    socket.on('error', () => {});
+    socket.on('close', () => resolve(data));
+  });
+}
+
+async function test(version, { name, context, accepted }) {
+  const server = tls.createServer({
+    ...serverCreds,
+    ca: ca1,
+    requestCert: true,
+    rejectUnauthorized: true,
+    minVersion: version,
+    maxVersion: version,
+  }, common.mustCall((socket) => {
+    assert.strictEqual(socket.servername, 'protected.example');
+    socket.end(socket.getPeerCertificate().subject.CN);
+  }));
+  server.on('tlsClientError', () => {});
+  server.addContext('protected.example', context);
+  await new Promise((resolve) => server.listen(0, resolve));
+
+  const { port } = server.address();
+  for (const client of Object.keys(clients)) {
+    const expected = client === accepted ? client : '';
+    assert.strictEqual(await connect(port, client, version), expected,
+                       `${version}, ${name}: ${client}`);
+  }
+  server.close();
+}
+
+(async () => {
+  for (const version of ['TLSv1.2', 'TLSv1.3']) {
+    for (const c of cases)
+      await test(version, c);
+  }
+})().then(common.mustCall());