chore: cherry-pick tls shutdown crash fix from upstream (#39928)

This commit is contained in:
Robo 2023-09-21 06:47:31 -07:00 committed by GitHub
parent ba8915242a
commit 5f712fa325
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 437 additions and 0 deletions

View file

@ -44,3 +44,7 @@ chore_update_fixtures_errors_force_colors_snapshot.patch
fix_assert_module_in_the_renderer_process.patch fix_assert_module_in_the_renderer_process.patch
src_cast_v8_object_getinternalfield_return_value_to_v8_value.patch src_cast_v8_object_getinternalfield_return_value_to_v8_value.patch
fix_add_trusted_space_and_trusted_lo_space_to_the_v8_heap.patch fix_add_trusted_space_and_trusted_lo_space_to_the_v8_heap.patch
tls_ensure_tls_sockets_are_closed_if_the_underlying_wrap_closes.patch
test_deflake_test-tls-socket-close.patch
net_fix_crash_due_to_simultaneous_close_shutdown_on_js_stream.patch
net_use_asserts_in_js_socket_stream_to_catch_races_in_future.patch

View file

@ -0,0 +1,171 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Tim Perry <pimterry@gmail.com>
Date: Thu, 24 Aug 2023 16:05:02 +0100
Subject: net: fix crash due to simultaneous close/shutdown on JS Stream
Sockets
A JS stream socket wraps a stream, exposing it as a socket for something
on top which needs a socket specifically (e.g. an HTTP server).
If the internal stream is closed in the same tick as the layer on top
attempts to close this stream, the race between doShutdown and doClose
results in an uncatchable exception. A similar race can happen with
doClose and doWrite.
It seems legitimate these can happen in parallel, so this resolves that
by explicitly detecting and handling that situation: if a close is in
progress, both doShutdown & doWrite allow doClose to run
finishShutdown/Write for them, cancelling the operation, without trying
to use this._handle (which will be null) in the meantime.
PR-URL: https://github.com/nodejs/node/pull/49400
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
diff --git a/lib/internal/js_stream_socket.js b/lib/internal/js_stream_socket.js
index 8bc19296620b3fd0e5487165743f0f1bc2d342e7..68e1802a63b012b59418b79a0e68de5147543a23 100644
--- a/lib/internal/js_stream_socket.js
+++ b/lib/internal/js_stream_socket.js
@@ -21,6 +21,7 @@ const { ERR_STREAM_WRAP } = require('internal/errors').codes;
const kCurrentWriteRequest = Symbol('kCurrentWriteRequest');
const kCurrentShutdownRequest = Symbol('kCurrentShutdownRequest');
const kPendingShutdownRequest = Symbol('kPendingShutdownRequest');
+const kPendingClose = Symbol('kPendingClose');
function isClosing() { return this[owner_symbol].isClosing(); }
@@ -94,6 +95,7 @@ class JSStreamSocket extends Socket {
this[kCurrentWriteRequest] = null;
this[kCurrentShutdownRequest] = null;
this[kPendingShutdownRequest] = null;
+ this[kPendingClose] = false;
this.readable = stream.readable;
this.writable = stream.writable;
@@ -135,10 +137,17 @@ class JSStreamSocket extends Socket {
this[kPendingShutdownRequest] = req;
return 0;
}
+
assert(this[kCurrentWriteRequest] === null);
assert(this[kCurrentShutdownRequest] === null);
this[kCurrentShutdownRequest] = req;
+ if (this[kPendingClose]) {
+ // If doClose is pending, the stream & this._handle are gone. We can't do
+ // anything. doClose will call finishShutdown with ECANCELED for us shortly.
+ return 0;
+ }
+
const handle = this._handle;
process.nextTick(() => {
@@ -164,6 +173,13 @@ class JSStreamSocket extends Socket {
assert(this[kCurrentWriteRequest] === null);
assert(this[kCurrentShutdownRequest] === null);
+ if (this[kPendingClose]) {
+ // If doClose is pending, the stream & this._handle are gone. We can't do
+ // anything. doClose will call finishWrite with ECANCELED for us shortly.
+ this[kCurrentWriteRequest] = req; // Store req, for doClose to cancel
+ return 0;
+ }
+
const handle = this._handle;
const self = this;
@@ -217,6 +233,8 @@ class JSStreamSocket extends Socket {
}
doClose(cb) {
+ this[kPendingClose] = true;
+
const handle = this._handle;
// When sockets of the "net" module destroyed, they will call
@@ -234,6 +252,8 @@ class JSStreamSocket extends Socket {
this.finishWrite(handle, uv.UV_ECANCELED);
this.finishShutdown(handle, uv.UV_ECANCELED);
+ this[kPendingClose] = false;
+
cb();
});
}
diff --git a/test/parallel/test-http2-client-connection-tunnelling.js b/test/parallel/test-http2-client-connection-tunnelling.js
new file mode 100644
index 0000000000000000000000000000000000000000..6e04121ca71ea81f49c7f50ec11d7fac735c80a9
--- /dev/null
+++ b/test/parallel/test-http2-client-connection-tunnelling.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const common = require('../common');
+const fixtures = require('../common/fixtures');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const net = require('net');
+const tls = require('tls');
+const h2 = require('http2');
+
+// This test sets up an H2 proxy server, and tunnels a request over one of its streams
+// back to itself, via TLS, and then closes the TLS connection. On some Node versions
+// (v18 & v20 up to 20.5.1) the resulting JS Stream Socket fails to shutdown correctly
+// in this case, and crashes due to a null pointer in finishShutdown.
+
+const tlsOptions = {
+ key: fixtures.readKey('agent1-key.pem'),
+ cert: fixtures.readKey('agent1-cert.pem'),
+ ALPNProtocols: ['h2']
+};
+
+const netServer = net.createServer((socket) => {
+ socket.allowHalfOpen = false;
+ // ^ This allows us to trigger this reliably, but it's not strictly required
+ // for the bug and crash to happen, skipping this just fails elsewhere later.
+
+ h2Server.emit('connection', socket);
+});
+
+const h2Server = h2.createSecureServer(tlsOptions, (req, res) => {
+ res.writeHead(200);
+ res.end();
+});
+
+h2Server.on('connect', (req, res) => {
+ res.writeHead(200, {});
+ netServer.emit('connection', res.stream);
+});
+
+netServer.listen(0, common.mustCall(() => {
+ const proxyClient = h2.connect(`https://localhost:${netServer.address().port}`, {
+ rejectUnauthorized: false
+ });
+
+ const proxyReq = proxyClient.request({
+ ':method': 'CONNECT',
+ ':authority': 'example.com:443'
+ });
+
+ proxyReq.on('response', common.mustCall((response) => {
+ assert.strictEqual(response[':status'], 200);
+
+ // Create a TLS socket within the tunnel, and start sending a request:
+ const tlsSocket = tls.connect({
+ socket: proxyReq,
+ ALPNProtocols: ['h2'],
+ rejectUnauthorized: false
+ });
+
+ proxyReq.on('close', common.mustCall(() => {
+ proxyClient.close();
+ netServer.close();
+ }));
+
+ // Forcibly kill the TLS socket
+ tlsSocket.destroy();
+
+ // This results in an async error in affected Node versions, before the 'close' event
+ }));
+}));

View file

@ -0,0 +1,30 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Tim Perry <pimterry@gmail.com>
Date: Fri, 25 Aug 2023 14:16:35 +0100
Subject: net: use asserts in JS Socket Stream to catch races in future
PR-URL: https://github.com/nodejs/node/pull/49400
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
diff --git a/lib/internal/js_stream_socket.js b/lib/internal/js_stream_socket.js
index 68e1802a63b012b59418b79a0e68de5147543a23..70d6d03069f3f1e85e66864c6c1e6de6084f5ea6 100644
--- a/lib/internal/js_stream_socket.js
+++ b/lib/internal/js_stream_socket.js
@@ -149,6 +149,7 @@ class JSStreamSocket extends Socket {
}
const handle = this._handle;
+ assert(handle !== null);
process.nextTick(() => {
// Ensure that write is dispatched asynchronously.
@@ -181,6 +182,8 @@ class JSStreamSocket extends Socket {
}
const handle = this._handle;
+ assert(handle !== null);
+
const self = this;
let pending = bufs.length;

View file

@ -0,0 +1,28 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Luigi Pinca <luigipinca@gmail.com>
Date: Wed, 13 Sep 2023 08:04:39 +0200
Subject: test: deflake test-tls-socket-close
Move the check for the destroyed state of the remote socket to the inner
`setImmediate()`.
Refs: https://github.com/nodejs/node/pull/49327#issuecomment-1712525257
PR-URL: https://github.com/nodejs/node/pull/49575
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
diff --git a/test/parallel/test-tls-socket-close.js b/test/parallel/test-tls-socket-close.js
index 667b291309a4c5636a2c658fa8204b32c2e4df46..70af760d53bb4ddab62c99180d505e943ec269f6 100644
--- a/test/parallel/test-tls-socket-close.js
+++ b/test/parallel/test-tls-socket-close.js
@@ -44,9 +44,9 @@ function connectClient(server) {
setImmediate(() => {
assert.strictEqual(netSocket.destroyed, true);
- assert.strictEqual(clientTlsSocket.destroyed, true);
setImmediate(() => {
+ assert.strictEqual(clientTlsSocket.destroyed, true);
assert.strictEqual(serverTlsSocket.destroyed, true);
tlsServer.close();

View file

@ -0,0 +1,204 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Tim Perry <1526883+pimterry@users.noreply.github.com>
Date: Fri, 1 Sep 2023 09:00:05 +0200
Subject: tls: ensure TLS Sockets are closed if the underlying wrap closes
This fixes a potential segfault, among various other likely-related
issues, which all occur because TLSSockets were not informed if their
underlying stream was closed in many cases.
This also significantly modifies an existing TLS test. With this change
in place, that test no longer works, as it tries to mess with internals
to trigger a race, and those internals are now cleaned up earlier. This
test has been simplified to a more general TLS shutdown test.
PR-URL: https://github.com/nodejs/node/pull/49327
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Debadree Chatterjee <debadree333@gmail.com>
diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js
index 1eff3b3fba8a05d5fade43c6cb00b6621daa8c3d..5bed23e1355e5e5a1965f15ca270fb372f751d66 100644
--- a/lib/_tls_wrap.js
+++ b/lib/_tls_wrap.js
@@ -629,6 +629,9 @@ TLSSocket.prototype._wrapHandle = function(wrap, handle) {
defineHandleReading(this, handle);
this.on('close', onSocketCloseDestroySSL);
+ if (wrap) {
+ wrap.on('close', () => this.destroy());
+ }
return res;
};
diff --git a/test/parallel/test-http2-socket-close.js b/test/parallel/test-http2-socket-close.js
new file mode 100644
index 0000000000000000000000000000000000000000..02db77bcf8480c79c77175ba802f9fe10ffc4efe
--- /dev/null
+++ b/test/parallel/test-http2-socket-close.js
@@ -0,0 +1,67 @@
+'use strict';
+
+const common = require('../common');
+const fixtures = require('../common/fixtures');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const net = require('net');
+const h2 = require('http2');
+
+const tlsOptions = {
+ key: fixtures.readKey('agent1-key.pem'),
+ cert: fixtures.readKey('agent1-cert.pem'),
+ ALPNProtocols: ['h2']
+};
+
+// Create a net server that upgrades sockets to HTTP/2 manually, handles the
+// request, and then shuts down via a short socket timeout and a longer H2 session
+// timeout. This is an unconventional way to shut down a session (the underlying
+// socket closing first) but it should work - critically, it shouldn't segfault
+// (as it did until Node v20.5.1).
+
+let serverRawSocket;
+let serverH2Session;
+
+const netServer = net.createServer((socket) => {
+ serverRawSocket = socket;
+ h2Server.emit('connection', socket);
+});
+
+const h2Server = h2.createSecureServer(tlsOptions, (req, res) => {
+ res.writeHead(200);
+ res.end();
+});
+
+h2Server.on('session', (session) => {
+ serverH2Session = session;
+});
+
+netServer.listen(0, common.mustCall(() => {
+ const proxyClient = h2.connect(`https://localhost:${netServer.address().port}`, {
+ rejectUnauthorized: false
+ });
+
+ proxyClient.on('close', common.mustCall(() => {
+ netServer.close();
+ }));
+
+ const req = proxyClient.request({
+ ':method': 'GET',
+ ':path': '/'
+ });
+
+ req.on('response', common.mustCall((response) => {
+ assert.strictEqual(response[':status'], 200);
+
+ // Asynchronously shut down the server's connections after the response,
+ // but not in the order it typically expects:
+ setTimeout(() => {
+ serverRawSocket.destroy();
+
+ setTimeout(() => {
+ serverH2Session.close();
+ }, 10);
+ }, 10);
+ }));
+}));
diff --git a/test/parallel/test-tls-socket-close.js b/test/parallel/test-tls-socket-close.js
index 87355cf8d7bd2d07bb0fab59491b68f3963f8809..667b291309a4c5636a2c658fa8204b32c2e4df46 100644
--- a/test/parallel/test-tls-socket-close.js
+++ b/test/parallel/test-tls-socket-close.js
@@ -8,37 +8,18 @@ const tls = require('tls');
const net = require('net');
const fixtures = require('../common/fixtures');
-// Regression test for https://github.com/nodejs/node/issues/8074
-//
-// This test has a dependency on the order in which the TCP connection is made,
-// and TLS server handshake completes. It assumes those server side events occur
-// before the client side write callback, which is not guaranteed by the TLS
-// API. It usually passes with TLS1.3, but TLS1.3 didn't exist at the time the
-// bug existed.
-//
-// Pin the test to TLS1.2, since the test shouldn't be changed in a way that
-// doesn't trigger a segfault in Node.js 7.7.3:
-// https://github.com/nodejs/node/issues/13184#issuecomment-303700377
-tls.DEFAULT_MAX_VERSION = 'TLSv1.2';
-
const key = fixtures.readKey('agent2-key.pem');
const cert = fixtures.readKey('agent2-cert.pem');
-let tlsSocket;
-// tls server
+let serverTlsSocket;
const tlsServer = tls.createServer({ cert, key }, (socket) => {
- tlsSocket = socket;
- socket.on('error', common.mustCall((error) => {
- assert.strictEqual(error.code, 'EINVAL');
- tlsServer.close();
- netServer.close();
- }));
+ serverTlsSocket = socket;
});
+// A plain net server, that manually passes connections to the TLS
+// server to be upgraded
let netSocket;
-// plain tcp server
const netServer = net.createServer((socket) => {
- // If client wants to use tls
tlsServer.emit('connection', socket);
netSocket = socket;
@@ -46,35 +27,32 @@ const netServer = net.createServer((socket) => {
connectClient(netServer);
}));
+// A client that connects, sends one message, and closes the raw connection:
function connectClient(server) {
- const tlsConnection = tls.connect({
+ const clientTlsSocket = tls.connect({
host: 'localhost',
port: server.address().port,
rejectUnauthorized: false
});
- tlsConnection.write('foo', 'utf8', common.mustCall(() => {
+ clientTlsSocket.write('foo', 'utf8', common.mustCall(() => {
assert(netSocket);
netSocket.setTimeout(common.platformTimeout(10), common.mustCall(() => {
- assert(tlsSocket);
- // This breaks if TLSSocket is already managing the socket:
+ assert(serverTlsSocket);
+
netSocket.destroy();
- const interval = setInterval(() => {
- // Checking this way allows us to do the write at a time that causes a
- // segmentation fault (not always, but often) in Node.js 7.7.3 and
- // earlier. If we instead, for example, wait on the `close` event, then
- // it will not segmentation fault, which is what this test is all about.
- if (tlsSocket._handle._parent.bytesRead === 0) {
- tlsSocket.write('bar');
- clearInterval(interval);
- }
- }, 1);
+
+ setImmediate(() => {
+ assert.strictEqual(netSocket.destroyed, true);
+ assert.strictEqual(clientTlsSocket.destroyed, true);
+
+ setImmediate(() => {
+ assert.strictEqual(serverTlsSocket.destroyed, true);
+
+ tlsServer.close();
+ netServer.close();
+ });
+ });
}));
}));
- tlsConnection.on('error', (e) => {
- // Tolerate the occasional ECONNRESET.
- // Ref: https://github.com/nodejs/node/issues/13184
- if (e.code !== 'ECONNRESET')
- throw e;
- });
}