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