From 0c036408fab38e123f244b8261d3c3468e7355ed Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 13 Apr 2026 09:53:48 +0200 Subject: [PATCH] tls: bind reusable sessions to authenticated host Backport-PR-URL: https://github.com/nodejs-private/node-private/pull/895 PR-URL: https://github.com/nodejs-private/node-private/pull/854 Reviewed-By: Antoine du Hamel CVE-ID: CVE-2026-48934 Refs: https://hackerone.com/reports/3649802 origin: backport, https://github.com/nodejs/node/commit/fd890ba01d508ac111bbba302981d7fdf734d2ce bug: https://nodejs.org/en/blog/vulnerability/june-2026-security-releases#tls-host-identity-verification-bypass-via-session-reuse-with-different-servername-leads-to-unauthorized-connections-cve-2026-48934---medium Gbp-Pq: Topic sec Gbp-Pq: Name CVE-2026-48934.patch --- lib/_tls_wrap.js | 109 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 11 deletions(-) diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js index 410de6c9e..95f74f827 100644 --- a/lib/_tls_wrap.js +++ b/lib/_tls_wrap.js @@ -119,6 +119,82 @@ const kIsVerified = Symbol('verified'); const noop = FunctionPrototype; +const kTLSSessionStatePrefix = Buffer.from('\0nodejs:tls:session:1\0'); + +function getSessionServerIdentity(options) { + return options?.servername || + options?.host || + options?.socket?._host || + 'localhost'; +} + +function wrapSessionState(session, options) { + if (!Buffer.isBuffer(session) || options?.isServer) + return session; + + const servername = Buffer.from(getSessionServerIdentity(options), 'utf8'); + const servernameLength = Buffer.allocUnsafe(2); + servernameLength.writeUInt16BE(servername.length, 0); + + return Buffer.concat([ + kTLSSessionStatePrefix, + servernameLength, + servername, + session, + ]); +} + +function unwrapSessionState(session) { + if (!Buffer.isBuffer(session) || + session.length < kTLSSessionStatePrefix.length + 2 || + Buffer.compare( + session.subarray(0, kTLSSessionStatePrefix.length), + kTLSSessionStatePrefix, + ) !== 0) { + return; + } + + const start = kTLSSessionStatePrefix.length; + const servernameLength = session.readUInt16BE(start); + const servernameStart = start + 2; + const servernameEnd = servernameStart + servernameLength; + if (session.length < servernameEnd) + return; + + return { + servername: session.toString('utf8', servernameStart, servernameEnd), + session: session.subarray(servernameEnd), + }; +} + +function getSessionForReuse(session, options) { + if (typeof session === 'string') + session = Buffer.from(session, 'latin1'); + + if (options?.isServer) + return session; + + const wrappedSession = unwrapSessionState(session); + if (wrappedSession !== undefined) { + const servername = getSessionServerIdentity(options); + if (wrappedSession.servername !== servername) { + debug('ignore session for %s: authenticated for %s', + servername, wrappedSession.servername); + return; + } + + return wrappedSession.session; + } + + if (Buffer.isBuffer(session) && options?.rejectUnauthorized !== false) { + debug('ignore raw session for verified client connection to %s', + getSessionServerIdentity(options)); + return; + } + + return session; +} + let ipServernameWarned = false; let tlsTracingWarned = false; @@ -347,10 +423,11 @@ function requestOCSPDone(socket) { function onnewsessionclient(sessionId, session) { debug('client emit session'); const owner = this[owner_symbol]; + const wrappedSession = wrapSessionState(session, owner[kConnectOptions]); if (owner[kIsVerified]) { - owner.emit('session', session); + owner.emit('session', wrappedSession); } else { - owner[kPendingSession] = session; + owner[kPendingSession] = wrappedSession; } } @@ -1123,9 +1200,19 @@ TLSSocket.prototype.setServername = function(name) { }; TLSSocket.prototype.setSession = function(session) { - if (typeof session === 'string') - session = Buffer.from(session, 'latin1'); - this._handle.setSession(session); + session = getSessionForReuse(session, this[kConnectOptions] || this._tlsOptions); + if (session !== undefined) + this._handle.setSession(session); +}; + +TLSSocket.prototype.getSession = function() { + if (!this._handle) + return null; + + return wrapSessionState( + this._handle.getSession(), + this[kConnectOptions] || this._tlsOptions, + ); }; TLSSocket.prototype.getPeerCertificate = function(detailed) { @@ -1173,7 +1260,6 @@ ArrayPrototypeForEach([ 'getFinished', 'getPeerFinished', 'getProtocol', - 'getSession', 'getTLSTicket', 'isSessionReused', 'enableTrace', @@ -1684,12 +1770,11 @@ function onConnectSecure() { // Verify that server's identity matches it's certificate's names // Unless server has resumed our existing session if (!verifyError && !this.isSessionReused()) { - const hostname = options.servername || - options.host || - (options.socket && options.socket._host) || - 'localhost'; const cert = this.getPeerCertificate(true); - verifyError = options.checkServerIdentity(hostname, cert); + verifyError = options.checkServerIdentity( + getSessionServerIdentity(options), + cert, + ); } if (verifyError) { @@ -1772,6 +1857,8 @@ exports.connect = function connect(...args) { const context = options.secureContext || tls.createSecureContext(options); + options.session = getSessionForReuse(options.session, options); + const tlssock = new TLSSocket(options.socket, { allowHalfOpen: options.allowHalfOpen, pipe: !!options.path, -- 2.39.5