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());