Skip to content

Commit e899d0e

Browse files
ViniciusDev26aduh95
authored andcommitted
tls: propagate singleUse to the secure context
tls.connect() sets options.singleUse, and both TLSWrap.prototype.close() and TLSSocket.prototype._destroySSL() check _secureContext.singleUse before closing the context. Nothing ever assigned the flag to the context, so the check never fired and each connection's SSL_CTX stayed alive until the JS wrapper was garbage collected. The assignment was lost when the context configuration was extracted into configSecureContext(). Restore it in createSecureContext(), which owns the JS wrapper the flag is read from, and make the close idempotent: a socket that swaps its handle, as autoSelectFamily retries do, shares a single context between the successive TLSWraps and each of them reaches the same teardown. Signed-off-by: Carlos Vinicius <viniciusdev.26@gmail.com> PR-URL: #66025 Fixes: #66002 Refs: #38116 Reviewed-By: Xuguang Mei <meixuguang@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent b45e5d0 commit e899d0e

3 files changed

Lines changed: 165 additions & 11 deletions

File tree

‎lib/internal/tls/common.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function createSecureContext(options) {
100100
minVersion,
101101
maxVersion,
102102
secureProtocol,
103+
singleUse,
103104
} = options;
104105

105106
let { secureOptions } = options;
@@ -112,6 +113,13 @@ function createSecureContext(options) {
112113

113114
configSecureContext(c.context, options);
114115

116+
// A single-use context belongs to exactly one connection, so the underlying
117+
// SSL_CTX can be freed as soon as that connection closes instead of waiting
118+
// for the JS wrapper to be garbage collected. TLSSocket relies on this flag
119+
// to know that it may close the context itself.
120+
if (singleUse)
121+
c.singleUse = true;
122+
115123
return c;
116124
}
117125

‎lib/internal/tls/wrap.js‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const kDisableRenegotiation = Symbol('disable-renegotiation');
104104
const kErrorEmitted = Symbol('error-emitted');
105105
const kHandshakeTimeout = Symbol('handshake-timeout');
106106
const kRes = Symbol('res');
107+
const kSecureContext = Symbol('secureContext');
107108
const kSNICallback = Symbol('snicallback');
108109
const kALPNCallback = Symbol('alpncallback');
109110
const kEnableTrace = Symbol('enableTrace');
@@ -651,6 +652,7 @@ function TLSSocket(socket, opts) {
651652
this.authorized = false;
652653
this.authorizationError = null;
653654
this[kRes] = null;
655+
this[kSecureContext] = null;
654656
this[kIsVerified] = false;
655657
this[kPendingSession] = null;
656658

@@ -749,6 +751,18 @@ for (const proxiedMethod of proxiedMethods) {
749751
makeMethodProxy(proxiedMethod);
750752
}
751753

754+
// A single-use context is owned by the socket that created it, so its SSL_CTX
755+
// can be released as soon as the socket is gone instead of waiting for the JS
756+
// wrapper to be garbage collected. It is shared by every handle the socket
757+
// goes through, so it must outlive any individual TLSWrap.
758+
function closeSingleUseContext(secureContext) {
759+
if (secureContext !== null && secureContext.singleUse &&
760+
secureContext.context !== null) {
761+
secureContext.context.close();
762+
secureContext.context = null;
763+
}
764+
}
765+
752766
tls_wrap.TLSWrap.prototype.close = function close(cb) {
753767
let ssl;
754768
if (this[owner_symbol]) {
@@ -761,10 +775,6 @@ tls_wrap.TLSWrap.prototype.close = function close(cb) {
761775
const done = () => {
762776
if (ssl) {
763777
ssl.destroySSL();
764-
if (ssl._secureContext.singleUse) {
765-
ssl._secureContext.context.close();
766-
ssl._secureContext.context = null;
767-
}
768778
}
769779
if (cb)
770780
cb();
@@ -822,6 +832,7 @@ TLSSocket.prototype._wrapHandle = function(wrap, handle, wrapHasActiveWriteFromP
822832
res._parent = handle; // C++ "wrap" object: TCPWrap, JSStream, ...
823833
res._parentWrap = wrap; // JS object: net.Socket, JSStreamSocket, ...
824834
res._secureContext = context;
835+
this[kSecureContext] = context;
825836
res.reading = handle.reading;
826837
this[kRes] = res;
827838
defineHandleReading(this, handle);
@@ -887,13 +898,16 @@ function destroySSL(self) {
887898
}
888899

889900
TLSSocket.prototype._destroySSL = function _destroySSL() {
890-
if (!this.ssl) return;
891-
this.ssl.destroySSL();
892-
if (this.ssl._secureContext.singleUse) {
893-
this.ssl._secureContext.context.close();
894-
this.ssl._secureContext.context = null;
895-
}
896-
this.ssl = null;
901+
if (this.ssl) {
902+
this.ssl.destroySSL();
903+
this.ssl = null;
904+
}
905+
// Release the context here rather than when a handle closes: a socket that
906+
// swaps handles, as autoSelectFamily retries do, shares one context across
907+
// the successive TLSWraps and still needs it after the old handle is gone.
908+
// TLSWrap.close() may already have cleared `ssl` on the way here.
909+
closeSingleUseContext(this[kSecureContext]);
910+
this[kSecureContext] = null;
897911
this[kPendingSession] = null;
898912
this[kIsVerified] = false;
899913
};
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
5+
if (!common.hasCrypto)
6+
common.skip('missing crypto');
7+
8+
// tls.connect() builds a SecureContext per connection and marks it as single
9+
// use, so that the underlying SSL_CTX is released as soon as the socket
10+
// closes instead of when the JS wrapper happens to be garbage collected.
11+
// Regression test for https://github.com/nodejs/node/issues/66002, where the
12+
// flag was lost on its way to the context and nothing was ever closed.
13+
14+
const assert = require('assert');
15+
const tls = require('tls');
16+
const fixtures = require('../common/fixtures');
17+
const { createMockedLookup } = require('../common/dns');
18+
19+
const key = fixtures.readKey('agent1-key.pem');
20+
const cert = fixtures.readKey('agent1-cert.pem');
21+
22+
const server = tls.createServer({ key, cert }, (conn) => conn.end());
23+
24+
// Bound to a single address so that the addresses the handle-swap case
25+
// retries through are refused rather than answered by this server.
26+
server.listen(0, '127.0.0.1', common.mustCall(() => {
27+
connectWithOwnContext(common.mustCall(() => {
28+
connectWithSharedContext(common.mustCall(() => {
29+
connectAcrossHandleSwaps(common.mustCall(() => {
30+
connectWithKeepAlive(common.mustCall(() => server.close()));
31+
}));
32+
}));
33+
}));
34+
}));
35+
36+
// _destroySSL() runs from the immediate queue, after the 'close' event.
37+
function afterDestroySSL(socket, fn) {
38+
socket.on('close', common.mustCall(() => setImmediate(fn)));
39+
}
40+
41+
function connectWithOwnContext(done) {
42+
const socket = tls.connect({
43+
host: '127.0.0.1',
44+
port: server.address().port,
45+
rejectUnauthorized: false,
46+
}, common.mustCall(() => {
47+
const secureContext = socket.ssl._secureContext;
48+
assert.strictEqual(secureContext.singleUse, true);
49+
assert.notStrictEqual(secureContext.context, null);
50+
51+
afterDestroySSL(socket, common.mustCall(() => {
52+
assert.strictEqual(secureContext.context, null);
53+
done();
54+
}));
55+
}));
56+
}
57+
58+
function connectWithSharedContext(done) {
59+
// A context passed in by the user may outlive the connection, so it must
60+
// not be marked single use, and it must still work for the next socket.
61+
const secureContext = tls.createSecureContext();
62+
let remaining = 2;
63+
64+
(function connectOnce() {
65+
const socket = tls.connect({
66+
host: '127.0.0.1',
67+
port: server.address().port,
68+
rejectUnauthorized: false,
69+
secureContext,
70+
}, common.mustCall(() => {
71+
assert.strictEqual(socket.ssl._secureContext, secureContext);
72+
assert.strictEqual(secureContext.singleUse, undefined);
73+
74+
afterDestroySSL(socket, common.mustCall(() => {
75+
assert.notStrictEqual(secureContext.context, null);
76+
if (--remaining === 0) done();
77+
else connectOnce();
78+
}));
79+
}));
80+
})();
81+
}
82+
83+
function connectAcrossHandleSwaps(done) {
84+
// autoSelectFamily reinitializes the handle on every failed attempt, and the
85+
// successive TLSWraps share the socket's context. Releasing it with the old
86+
// handle leaves the next attempt without a context. Two failing addresses
87+
// are needed: the close happens on the first swap, and the next swap is what
88+
// trips over it.
89+
const socket = tls.connect({
90+
host: 'example.org',
91+
port: server.address().port,
92+
rejectUnauthorized: false,
93+
autoSelectFamily: true,
94+
autoSelectFamilyAttemptTimeout:
95+
common.defaultAutoSelectFamilyAttemptTimeout,
96+
lookup: createMockedLookup('::1', '127.0.0.2', '127.0.0.1'),
97+
}, common.mustCall(() => {
98+
// `ssl` is cleared while the handle is swapped, so read the context from
99+
// the handle the socket ended up with.
100+
const secureContext = socket._handle._secureContext;
101+
assert.strictEqual(secureContext.singleUse, true);
102+
assert.notStrictEqual(secureContext.context, null);
103+
104+
afterDestroySSL(socket, common.mustCall(() => {
105+
assert.strictEqual(secureContext.context, null);
106+
done();
107+
}));
108+
}));
109+
}
110+
111+
function connectWithKeepAlive(done) {
112+
// tls.connect() only asks for a single-use context when TCP keepalive is
113+
// off, so a client that enables it leaves its context to the garbage
114+
// collector. Pinned here as the current behaviour: whether the context
115+
// should be released early in this case too is the open question in the
116+
// issue, and is deliberately not decided by this change.
117+
const socket = tls.connect({
118+
host: '127.0.0.1',
119+
port: server.address().port,
120+
rejectUnauthorized: false,
121+
keepAlive: true,
122+
}, common.mustCall(() => {
123+
const secureContext = socket.ssl._secureContext;
124+
assert.strictEqual(secureContext.singleUse, undefined);
125+
assert.notStrictEqual(secureContext.context, null);
126+
127+
afterDestroySSL(socket, common.mustCall(() => {
128+
assert.notStrictEqual(secureContext.context, null);
129+
done();
130+
}));
131+
}));
132+
}

0 commit comments

Comments
 (0)