Commit 42213da8fdb for nodejs

commit 42213da8fdbd7d0de1b4ffab9b72a0fa973d5184
Author: Tim Perry <pimterry@gmail.com>
Date:   Fri Oct 30 00:00:21 2026 +0100

    tls: don't allow renegotiation or HRR to change SNI

    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/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js
index 82f343de726..7b64712544d 100644
--- a/lib/internal/tls/wrap.js
+++ b/lib/internal/tls/wrap.js
@@ -1206,7 +1206,7 @@ TLSSocket.prototype._finishInit = function() {
     return;

   this.alpnProtocol = this._handle.getALPNNegotiatedProtocol();
-  // The servername could be set by TLSWrap::SelectSNIContextCallback().
+  // On a server, TLSWrap::PinServerName() sets it from the first ClientHello.
   if (this.servername === null) {
     this.servername = this._handle.getServername();
   }
diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc
index 4c848425771..dd7c7cf882c 100644
--- a/src/crypto/crypto_tls.cc
+++ b/src/crypto/crypto_tls.cc
@@ -116,6 +116,12 @@ SSL_SESSION* GetSessionCallback(
   return w->ReleaseSession();
 }

+// The server name is handled in EarlyClientHelloCallback, so this only
+// acknowledges it, which also has the TLS library store it in new sessions.
+int AcknowledgeServerName(SSL* s, int* ad, void* arg) {
+  return SSL_TLSEXT_ERR_OK;
+}
+
 // The TLS library invokes this before version negotiation and before session
 // or ticket resumption, which makes async session lookup possible. If required
 // the handshake is suspended here and resumed once JS has answered.
@@ -129,10 +135,8 @@ ClientHelloResult EarlyClientHelloCallback(const ClientHelloContext& hello) {
     return ClientHelloResult::kFail;
   }

-  // Session resumption must only be allowed for sessions with exactly the same
-  // SNI. We use the SNI to derive the session id, so no handshake can resume
-  // from a mismatched SNI value. Mismatches fall back to full handshakes:
-  if (!w->BindSessionsToServerName(*servername)) {
+  if (!w->PinServerName(*servername)) {
+    hello.set_alert(SSL_AD_ILLEGAL_PARAMETER);
     return ClientHelloResult::kFail;
   }

@@ -593,8 +597,7 @@ void TLSWrap::InitSSL() {
   //   https://github.com/openssl/openssl/issues/7199#issuecomment-420670544
   SSL_set_info_callback(ssl_.get(), SSLInfoCallback);

-  if (is_server())
-    sc_->SetSelectSNIContextCallback(SelectSNIContextCallback);
+  if (is_server()) sc_->SetSelectSNIContextCallback(AcknowledgeServerName);

   ConfigureSecureContext(sc_.get());

@@ -1570,39 +1573,34 @@ void TLSWrap::SetServername(const FunctionCallbackInfo<Value>& args) {
   SSL_set_tlsext_host_name(wrap->ssl_.get(), *servername);
 }

-int TLSWrap::SelectSNIContextCallback(SSL* s, int* ad, void* arg) {
-  TLSWrap* p = static_cast<TLSWrap*>(SSL_get_app_data(s));
-  Environment* env = p->env();
-  HandleScope handle_scope(env->isolate());
-  Context::Scope context_scope(env->context());
-
-  auto servername = SSLPointer::GetServerName(s);
-  if (!servername.has_value() ||
-      !Set(env, p->GetOwner(), env->servername_string(), servername.value()))
-    return SSL_TLSEXT_ERR_NOACK;
-
-  Local<Value> ctx = p->object()->Get(env->context(), env->sni_context_string())
-      .FromMaybe(Local<Value>());
-
-  // Acknowlege the name so it's correctly stored on the session:
-  if (ctx.IsEmpty() || !ctx->IsObject()) return SSL_TLSEXT_ERR_OK;
-
-  if (!env->secure_context_constructor_template()->HasInstance(ctx)) {
-    // Failure: incorrect SNI context object
-    Local<Value> err = Exception::TypeError(env->sni_context_err_string());
-    p->MakeCallback(env->onerror_string(), 1, &err);
-    return SSL_TLSEXT_ERR_NOACK;
+// The connection's server name is fixed by its first ClientHello: later ones (a
+// renegotiation, or the second of a HelloRetryRequest) must ask for exactly the
+// same, as the connection's secure context was chosen for it.
+bool TLSWrap::PinServerName(std::string_view servername) {
+  if (flags_.servername_pinned) {
+    if (servername == servername_) return true;
+    Debug(this,
+          "ClientHello asked for server name '%s', connection pinned to '%s'",
+          servername,
+          servername_);
+    return false;
   }
+  flags_.servername_pinned = true;
+  servername_ = servername;

-  SecureContext* sc = Unwrap<SecureContext>(ctx.As<Object>());
-  CHECK_NOT_NULL(sc);
-  p->sni_context_ = BaseObjectPtr<SecureContext>(sc);
-
-  ConfigureSecureContext(sc);
-  CHECK_EQ(SSL_set_SSL_CTX(p->ssl_.get(), sc->ctx().get()), sc->ctx().get());
-  p->SetCACerts(sc);
+  if (!servername.empty()) {
+    HandleScope handle_scope(env()->isolate());
+    Context::Scope context_scope(env()->context());
+    if (!Set(env(), GetOwner(), env()->servername_string(), servername_)) {
+      return false;
+    }
+  }

-  return SSL_TLSEXT_ERR_OK;
+  // Session resumption must only be allowed for sessions with exactly the same
+  // SNI. We use the SNI to derive the session id context, so no handshake can
+  // resume from a mismatched SNI value. Mismatches fall back to full
+  // handshakes:
+  return BindSessionsToServerName(servername);
 }

 bool TLSWrap::BindSessionsToServerName(std::string_view servername) {
@@ -1781,6 +1779,7 @@ void TLSWrap::MemoryInfo(MemoryTracker* tracker) const {
   tracker->TrackField("ocsp_response", ocsp_response_);
   tracker->TrackField("sni_context", sni_context_);
   tracker->TrackField("error", error_);
+  tracker->TrackField("servername", servername_);
   if (pending_cleartext_input_)
     tracker->TrackField("pending_cleartext_input", pending_cleartext_input_);
   if (enc_in_ != nullptr)
diff --git a/src/crypto/crypto_tls.h b/src/crypto/crypto_tls.h
index cb57b7346eb..38cd52e2ace 100644
--- a/src/crypto/crypto_tls.h
+++ b/src/crypto/crypto_tls.h
@@ -96,6 +96,7 @@ class TLSWrap : public AsyncWrap,
   }
   inline void enable_session_callbacks() { flags_.session_callbacks = true; }
   bool BindSessionsToServerName(std::string_view servername);
+  bool PinServerName(std::string_view servername);
   inline bool is_server() const { return kind_ == Kind::kServer; }
   inline bool is_client() const { return kind_ == Kind::kClient; }
   inline bool is_awaiting_new_session() const {
@@ -231,8 +232,6 @@ class TLSWrap : public AsyncWrap,

   int SetCACerts(SecureContext* sc);

-  static int SelectSNIContextCallback(SSL* s, int* ad, void* arg);
-
   static void CertCbDone(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void ClientHelloDone(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void DestroySSL(const v8::FunctionCallbackInfo<v8::Value>& args);
@@ -316,6 +315,7 @@ class TLSWrap : public AsyncWrap,
   v8::Global<v8::ArrayBufferView> ocsp_response_;
   BaseObjectPtr<SecureContext> sni_context_;
   BaseObjectPtr<SecureContext> sc_;
+  std::string servername_;

   // BIO buffers hold encrypted data.
   BIO* enc_in_ = nullptr;   // StreamListener fills this for SSL_read().
@@ -353,6 +353,7 @@ class TLSWrap : public AsyncWrap,
     bool write_callback_scheduled : 1;
     bool has_active_write_issued_by_prev_listener : 1;
     bool alpn_callback_enabled : 1;
+    bool servername_pinned : 1;
   };
   Flags flags_{};

diff --git a/test/parallel/test-tls-renegotiate-servername.js b/test/parallel/test-tls-renegotiate-servername.js
new file mode 100644
index 00000000000..b7ea9416650
--- /dev/null
+++ b/test/parallel/test-tls-renegotiate-servername.js
@@ -0,0 +1,242 @@
+'use strict';
+
+// A connection's secure context - its certificate, and the CAs it checks client
+// certificates against - is chosen from the server name of its first client
+// hello, but a connection can do multiple hellos: a TLS 1.2 renegotiation, or
+// a TLS 1.3 HelloRetryRequest. These later client hellos must not be allowed
+// to subsequently change the server name, since it can associate state and
+// authorization (mTLS) from one SNI with another, unexpectedly.
+//
+// Node's own client can't change the name mid-connection, so the client here
+// drives the TLS library directly over memory BIOs, leaving the socket I/O to
+// node:net.
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+common.skipIfFFIMissing();
+
+if (common.isWindows)
+  common.skip('dlopen(null) is not supported on Windows');
+
+// SSL_ctrl() and SSL_CTX_ctrl() take and return a C `long`, which node:ffi has
+// no type for, so they're declared below as i64, which it only is on 64-bit.
+if (['ia32', 'arm'].includes(process.arch))
+  common.skip('a C long is not 64 bits here');
+
+// The SSL_ctrl() command numbers below are OpenSSL's, and BoringSSL neither
+// renegotiates nor reads the server name of a second ClientHello.
+// BoringSSL guards against the whole class of errors internally anyway, and
+// the FFI implementation for testing below is OpenSSL specific:
+if (require('../common/crypto').isBoringSSL)
+  common.skip('OpenSSL-specific');
+
+const assert = require('node:assert');
+const net = require('node:net');
+const tls = require('node:tls');
+const fixtures = require('../common/fixtures');
+const { dlopen } = require('node:ffi');
+
+const SSL_CTRL_SET_TLSEXT_HOSTNAME = 55;
+const SSL_CTRL_SET_MAX_PROTO_VERSION = 124;
+const TLSEXT_NAMETYPE_host_name = 0n;
+const TLS1_2_VERSION = 0x0303n;
+const SSL_ERROR_WANT_READ = 2;
+const SSL_ERROR_WANT_WRITE = 3;
+
+// Node's own OpenSSL, resolved out of the running process rather than loaded
+// from the system.
+const { functions: ssl } = dlopen(null, {
+  TLS_client_method: { arguments: [], return: 'pointer' },
+  SSL_CTX_new: { arguments: ['pointer'], return: 'pointer' },
+  SSL_CTX_free: { arguments: ['pointer'], return: 'void' },
+  SSL_CTX_ctrl: { arguments: ['pointer', 'i32', 'i64', 'pointer'], return: 'i64' },
+  SSL_new: { arguments: ['pointer'], return: 'pointer' },
+  SSL_free: { arguments: ['pointer'], return: 'void' },
+  SSL_ctrl: { arguments: ['pointer', 'i32', 'i64', 'string'], return: 'i64' },
+  SSL_set_bio: { arguments: ['pointer', 'pointer', 'pointer'], return: 'void' },
+  SSL_set_connect_state: { arguments: ['pointer'], return: 'void' },
+  SSL_do_handshake: { arguments: ['pointer'], return: 'i32' },
+  SSL_renegotiate: { arguments: ['pointer'], return: 'i32' },
+  SSL_get_error: { arguments: ['pointer', 'i32'], return: 'i32' },
+  SSL_get1_session: { arguments: ['pointer'], return: 'pointer' },
+  SSL_set_session: { arguments: ['pointer', 'pointer'], return: 'i32' },
+  SSL_session_reused: { arguments: ['pointer'], return: 'i32' },
+  SSL_SESSION_free: { arguments: ['pointer'], return: 'void' },
+  BIO_s_mem: { arguments: [], return: 'pointer' },
+  BIO_new: { arguments: ['pointer'], return: 'pointer' },
+  BIO_read: { arguments: ['pointer', 'buffer', 'i32'], return: 'i32' },
+  BIO_write: { arguments: ['pointer', 'buffer', 'i32'], return: 'i32' },
+});
+
+// A client whose records are carried by a plain socket. The server name is
+// settable at any point, which is the whole reason for this class.
+class RawClient {
+  constructor({ tls12Only = false, session } = {}) {
+    this.ctx = ssl.SSL_CTX_new(ssl.TLS_client_method());
+    if (tls12Only) {
+      ssl.SSL_CTX_ctrl(this.ctx, SSL_CTRL_SET_MAX_PROTO_VERSION, TLS1_2_VERSION, null);
+    }
+    this.ssl = ssl.SSL_new(this.ctx);
+    if (session) ssl.SSL_set_session(this.ssl, session);
+    this.rbio = ssl.BIO_new(ssl.BIO_s_mem());
+    this.wbio = ssl.BIO_new(ssl.BIO_s_mem());
+    // SSL_set_bio() takes ownership of both, so SSL_free() releases them.
+    ssl.SSL_set_bio(this.ssl, this.rbio, this.wbio);
+    ssl.SSL_set_connect_state(this.ssl);
+
+    this.buf = Buffer.alloc(16 * 1024);
+    this.incoming = [];
+    this.notify = null;
+    this.ended = false;
+    this.socket = net.connect(server.address().port, '127.0.0.1');
+    this.socket.on('data', (chunk) => {
+      this.incoming.push(chunk);
+      this.wake();
+    });
+    this.socket.on('close', () => {
+      this.ended = true;
+      this.wake();
+    });
+    this.socket.on('error', () => {});
+    this.connected = new Promise((resolve) => this.socket.once('connect', resolve));
+  }
+
+  wake() {
+    const notify = this.notify;
+    this.notify = null;
+    notify?.();
+  }
+
+  // A null name clears it, so the next ClientHello carries no extension.
+  setServername(name) {
+    ssl.SSL_ctrl(this.ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME, TLSEXT_NAMETYPE_host_name, name);
+  }
+
+  // Everything the TLS library has produced goes out on the socket.
+  flush() {
+    for (;;) {
+      const read = ssl.BIO_read(this.wbio, this.buf, this.buf.length);
+      if (read <= 0) break;
+      this.socket.write(Buffer.from(this.buf.subarray(0, read)));
+    }
+  }
+
+  // Hands the library the next thing to arrive, false once nothing more will.
+  async feed() {
+    while (this.incoming.length === 0) {
+      if (this.ended) return false;
+      await new Promise((resolve) => { this.notify = resolve; });
+    }
+    const chunk = this.incoming.shift();
+    ssl.BIO_write(this.rbio, chunk, chunk.length);
+    return true;
+  }
+
+  // `afterFirstRead` runs once the library has been handed the server's first
+  // flight but before it has written anything in reply. For a TLS 1.3
+  // HelloRetryRequest that is the gap between the two ClientHellos.
+  async handshake(afterFirstRead) {
+    let reads = 0;
+    for (;;) {
+      const result = ssl.SSL_do_handshake(this.ssl);
+      this.flush();
+      if (result === 1) return true;
+      const error = ssl.SSL_get_error(this.ssl, result);
+      if (error !== SSL_ERROR_WANT_READ && error !== SSL_ERROR_WANT_WRITE) return false;
+      if (!await this.feed()) return false;
+      if (++reads === 1) afterFirstRead?.();
+    }
+  }
+
+  async renegotiate() {
+    if (ssl.SSL_renegotiate(this.ssl) !== 1) return false;
+    return this.handshake();
+  }
+
+  close() {
+    this.socket.destroy();
+    ssl.SSL_free(this.ssl);
+    ssl.SSL_CTX_free(this.ctx);
+  }
+}
+
+const server = tls.createServer({
+  key: fixtures.readKey('agent2-key.pem'),
+  cert: fixtures.readKey('agent2-cert.pem'),
+  // Offering only this group makes the server answer a TLS 1.3 client, whose
+  // first key share is for a different curve, with a HelloRetryRequest.
+  ecdhCurve: 'P-521',
+}, (socket) => {
+  socket.on('error', () => {});
+  socket.resume();
+});
+server.on('tlsClientError', () => {});
+
+// Do a set of TLS 1.2 handshakes, asking for SNI `first` (or null) then
+// renegotiating for SNI `second` (or null), and returning whether that
+// was accepted. If `resume` is set, the `first` session is a resumption.
+async function renegotiating(first, second, { resume = false } = {}) {
+  let session = null;
+  let issuer = null;
+  if (resume) {
+    // The session must be used before the client it came from is closed:
+    // freeing that client's context makes its sessions unresumable.
+    issuer = new RawClient({ tls12Only: true });
+    await issuer.connected;
+    issuer.setServername(first);
+    assert.strictEqual(await issuer.handshake(), true);
+    session = ssl.SSL_get1_session(issuer.ssl);
+  }
+  const client = new RawClient({ tls12Only: true, session });
+  await client.connected;
+  client.setServername(first);
+  assert.strictEqual(await client.handshake(), true);
+  assert.strictEqual(ssl.SSL_session_reused(client.ssl) === 1, resume);
+  client.setServername(second);
+  const accepted = await client.renegotiate();
+  client.close();
+  if (resume) {
+    issuer.close();
+    ssl.SSL_SESSION_free(session);
+  }
+  return accepted;
+}
+
+// One TLS 1.3 handshake, asking for `first` in the ClientHello and `second` in
+// the one that answers the HelloRetryRequest, returning whether it succeeded.
+async function retrying(first, second) {
+  const client = new RawClient();
+  await client.connected;
+  client.setServername(first);
+  const accepted = await client.handshake(() => client.setServername(second));
+  client.close();
+  return accepted;
+}
+
+server.listen(0, common.mustCall(async () => {
+  // Renegotiating must ask for exactly the name the first handshake did, or
+  // none if that had none. Changing, dropping or adding a name is refused.
+  for (const resume of [false, true]) {
+    for (const [first, second, accepted] of [
+      ['agent1.example', 'agent1.example', true],
+      [null, null, true],
+      ['agent1.example', 'agent3.example', false],
+      ['agent1.example', null, false],
+      [null, 'agent1.example', false],
+    ]) {
+      assert.strictEqual(await renegotiating(first, second, { resume }),
+                         accepted,
+                         `${resume ? 'resuming then ' : ''}renegotiating ` +
+                         `from ${first} to ${second}`);
+    }
+  }
+
+  // TLS1.3 HelloRetryRequest can't change SNI either:
+  assert.strictEqual(await retrying('agent1.example', 'agent1.example'), true);
+  assert.strictEqual(await retrying('agent1.example', 'agent3.example'), false);
+
+  server.close();
+}));
diff --git a/test/parallel/test-tls-sni-context-renegotiate.js b/test/parallel/test-tls-sni-context-renegotiate.js
new file mode 100644
index 00000000000..756a9ad1e6e
--- /dev/null
+++ b/test/parallel/test-tls-sni-context-renegotiate.js
@@ -0,0 +1,57 @@
+'use strict';
+
+// Once a connection's secure context is chosen, later ClientHellos for the
+// same name - the second ClientHello of a TLS 1.3 HelloRetryRequest, or a TLS
+// 1.2 renegotiation - must leave it as it is. A context with no certificate of
+// its own (here one that only sets `ca`) keeps the server's certificate, and
+// must keep it for those too.
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('node:assert');
+const tls = require('node:tls');
+const fixtures = require('../common/fixtures');
+const { isBoringSSL } = require('../common/crypto');
+
+const server = tls.createServer({
+  key: fixtures.readKey('agent2-key.pem'),
+  cert: fixtures.readKey('agent2-cert.pem'),
+  // Offering only this group makes the server answer a TLS 1.3 client whose
+  // first key share is for another one with a HelloRetryRequest.
+  ecdhCurve: 'P-521',
+}, common.mustCall((socket) => {
+  assert.strictEqual(socket.servername, 'certless.example');
+  socket.resume();
+}, isBoringSSL ? 1 : 2));
+server.addContext('certless.example', { ca: fixtures.readKey('ca1-cert.pem') });
+
+function connect(maxVersion) {
+  return new Promise((resolve) => {
+    const socket = tls.connect({
+      port: server.address().port,
+      servername: 'certless.example',
+      rejectUnauthorized: false,
+      maxVersion,
+      ecdhCurve: 'P-256:P-521',
+    }, () => resolve(socket));
+  });
+}
+
+server.listen(0, common.mustCall(async () => {
+  const retried = await connect('TLSv1.3');
+  retried.end();
+
+  // BoringSSL servers don't support renegotiation.
+  if (!isBoringSSL) {
+    const renegotiating = await connect('TLSv1.2');
+    await new Promise((resolve) => {
+      renegotiating.renegotiate({}, common.mustSucceed(resolve));
+    });
+    renegotiating.end();
+  }
+
+  server.close();
+}));