Commit 5ebf6adc4bf for nodejs

commit 5ebf6adc4bf92318c5954124bde9aacceb2f572e
Author: Tim Perry <pimterry@gmail.com>
Date:   Fri Oct 30 17:53:39 2026 +0100

    tls: disable resumption for mTLS with no session id context

    Signed-off-by: Tim Perry <pimterry@gmail.com>
    PR-URL: https://github.com/nodejs-private/node-private/pull/1030
    Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
    CVE-ID: CVE-2026-123872

diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc
index dd7c7cf882c..a0e883f00b4 100644
--- a/src/crypto/crypto_tls.cc
+++ b/src/crypto/crypto_tls.cc
@@ -113,6 +113,7 @@ SSL_SESSION* GetSessionCallback(
     int* copy) {
   TLSWrap* w = static_cast<TLSWrap*>(SSL_get_app_data(s));
   *copy = 0;
+  if (w->is_resumption_disabled()) return nullptr;
   return w->ReleaseSession();
 }

@@ -1604,7 +1605,15 @@ bool TLSWrap::PinServerName(std::string_view servername) {
 }

 bool TLSWrap::BindSessionsToServerName(std::string_view servername) {
-  if (sc_->session_id_context().empty()) return true;
+  // If we have no session id context and we need to verify peers, we reject
+  // all resumption since we can't do so securely.
+  if (sc_->session_id_context().empty()) {
+    if (SSL_get_verify_mode(ssl_.get()) & SSL_VERIFY_PEER) {
+      SSL_set_options(ssl_.get(), SSL_OP_NO_TICKET);
+      flags_.resumption_disabled = true;
+    }
+    return true;
+  }

   // Server names can't contain NULs, so a NUL keeps the parts apart.
   std::string input = sc_->session_id_context();
diff --git a/src/crypto/crypto_tls.h b/src/crypto/crypto_tls.h
index 38cd52e2ace..f49a4904f15 100644
--- a/src/crypto/crypto_tls.h
+++ b/src/crypto/crypto_tls.h
@@ -95,6 +95,9 @@ class TLSWrap : public AsyncWrap,
     flags_.awaiting_new_session = on;
   }
   inline void enable_session_callbacks() { flags_.session_callbacks = true; }
+  inline bool is_resumption_disabled() const {
+    return flags_.resumption_disabled;
+  }
   bool BindSessionsToServerName(std::string_view servername);
   bool PinServerName(std::string_view servername);
   inline bool is_server() const { return kind_ == Kind::kServer; }
@@ -339,6 +342,7 @@ class TLSWrap : public AsyncWrap,
   struct Flags {
     bool session_callbacks : 1;
     bool awaiting_new_session : 1;
+    bool resumption_disabled : 1;
     // 'onclienthello' has been emitted for this connection.
     bool hello_emitted : 1;
     // JS has answered it by calling clientHelloDone().
diff --git a/test/parallel/test-tls-resume-no-session-id-context.js b/test/parallel/test-tls-resume-no-session-id-context.js
new file mode 100644
index 00000000000..7106d3192aa
--- /dev/null
+++ b/test/parallel/test-tls-resume-no-session-id-context.js
@@ -0,0 +1,80 @@
+'use strict';
+
+// A server with no session id context of its own - possible when creating a
+// TLSSocket directly - can't tell which application a session belongs to. One
+// that verifies client certificates mustn't resume any, or a session from an
+// application sharing its secure context (and so its ticket keys) would skip
+// the client's certificate check. It gets a full handshake instead.
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('node:assert');
+const net = require('node:net');
+const tls = require('node:tls');
+const fixtures = require('../common/fixtures');
+
+const secureContext = tls.createSecureContext({
+  key: fixtures.readKey('agent2-key.pem'),
+  cert: fixtures.readKey('agent2-cert.pem'),
+});
+
+// Whether the server resumed each connection it accepted, in order.
+const reused = [];
+
+async function listen(requestCert) {
+  const server = net.createServer((socket) => {
+    const tlsSocket = new tls.TLSSocket(socket, {
+      isServer: true,
+      secureContext,
+      requestCert,
+    });
+    tlsSocket.on('secure', () => {
+      reused.push(tlsSocket.isSessionReused());
+      tlsSocket.end();
+    });
+  });
+  await new Promise((resolve) => server.listen(0, resolve));
+  return server;
+}
+
+// Resolves with the first session issued, once the connection closes. This is
+// the raw session from the TLS library: on BoringSSL the 'session' event's
+// TLS 1.3 session can't be resumed.
+function connect(server, options) {
+  return new Promise((resolve) => {
+    let session;
+    const socket = tls.connect({
+      port: server.address().port,
+      rejectUnauthorized: false,
+      ...options,
+    });
+    const { onnewsession } = socket._handle;
+    socket._handle.onnewsession = function(id, raw) {
+      session ??= raw;
+      return onnewsession.call(this, id, raw);
+    };
+    socket.on('session', () => {});
+    socket.on('close', () => resolve(session));
+    socket.resume();
+  });
+}
+
+(async () => {
+  const open = await listen(false);
+  const verifying = await listen(true);
+
+  for (const maxVersion of ['TLSv1.2', 'TLSv1.3']) {
+    reused.length = 0;
+    const session = await connect(open, { maxVersion });
+    assert.ok(session);
+    await connect(open, { maxVersion, session });
+    await connect(verifying, { maxVersion, session });
+    assert.deepStrictEqual(reused, [false, true, false], maxVersion);
+  }
+
+  open.close();
+  verifying.close();
+})().then(common.mustCall());