src: fix HTTP2 mem leak on premature close and ERR_PROTO
authorRafaelGSS <rafael.nunu@hotmail.com>
Tue, 17 Dec 2024 19:58:03 +0000 (16:58 -0300)
committerBastien Roucariès <rouca@debian.org>
Mon, 6 Apr 2026 14:18:52 +0000 (16:18 +0200)
This commit fixes a memory leak when the socket is
suddenly closed by the peer (without GOAWAY notification)
and when invalid header (by nghttp2) is identified and the
connection is terminated by peer.

Refs: https://hackerone.com/reports/2841362
PR-URL: https://github.com/nodejs-private/node-private/pull/650
Reviewed-By: James M Snell <jasnell@gmail.com>
CVE-ID: CVE-2025-23085
origin: https://github.com/nodejs/node/commit/6cc8d58e6f97c37c228f134bd9b98246c8871fb1

Gbp-Pq: Name CVE-2025-23085.patch

lib/internal/http2/core.js
src/node_http2.cc
test/parallel/test-http2-connect-method-extended-cant-turn-off.js
test/parallel/test-http2-invalid-last-stream-id.js [new file with mode: 0644]
test/parallel/test-http2-options-max-headers-block-length.js
test/parallel/test-http2-options-max-headers-exceeds-nghttp2.js
test/parallel/test-http2-premature-close.js [new file with mode: 0644]

index 92ce193b0078a85973f04c2163610026bce79809..38844d30f308cb285d76cf6b17e71f60a96dedb9 100644 (file)
@@ -608,11 +608,20 @@ function onFrameError(id, type, code) {
     return;
   debugSessionObj(session, 'error sending frame type %d on stream %d, code: %d',
                   type, id, code);
-  const emitter = session[kState].streams.get(id) || session;
+
+  const stream = session[kState].streams.get(id);
+  const emitter = stream || session;
   emitter[kUpdateTimer]();
   emitter.emit('frameError', type, code, id);
-  session[kState].streams.get(id).close(code);
-  session.close();
+
+  // When a frameError happens is not uncommon that a pending GOAWAY
+  // package from nghttp2 is on flight with a correct error code.
+  // We schedule it using setImmediate to give some time for that
+  // package to arrive.
+  setImmediate(() => {
+    stream?.close(code);
+    session.close();
+  });
 }
 
 function onAltSvc(stream, origin, alt) {
index eb3506ff5e609b82d4b4a84ce23e84ad365cefac..38d47f0c6fd0d0db739ad9359e87b9ac8d65744e 100644 (file)
@@ -750,6 +750,7 @@ bool Http2Session::CanAddStream() {
 }
 
 void Http2Session::AddStream(Http2Stream* stream) {
+  Debug(this, "Adding stream: %d", stream->id());
   CHECK_GE(++statistics_.stream_count, 0);
   streams_[stream->id()] = BaseObjectPtr<Http2Stream>(stream);
   size_t size = streams_.size();
@@ -760,6 +761,7 @@ void Http2Session::AddStream(Http2Stream* stream) {
 
 
 BaseObjectPtr<Http2Stream> Http2Session::RemoveStream(int32_t id) {
+  Debug(this, "Removing stream: %d", id);
   BaseObjectPtr<Http2Stream> stream;
   if (streams_.empty())
     return stream;
@@ -936,6 +938,7 @@ int Http2Session::OnHeaderCallback(nghttp2_session* handle,
   if (UNLIKELY(!stream))
     return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
 
+  Debug(session, "handling header key/pair for stream %d", id);
   // If the stream has already been destroyed, ignore.
   if (!stream->is_destroyed() && !stream->AddHeader(name, value, flags)) {
     // This will only happen if the connected peer sends us more
@@ -1005,9 +1008,21 @@ int Http2Session::OnInvalidFrame(nghttp2_session* handle,
     return 1;
   }
 
-  // If the error is fatal or if error code is ERR_STREAM_CLOSED... emit error
+  // If the error is fatal or if error code is one of the following
+  // we emit and error:
+  //
+  // ERR_STREAM_CLOSED: An invalid frame has been received in a closed stream.
+  //
+  // ERR_PROTO: The RFC 7540 specifies:
+  // "An endpoint that encounters a connection error SHOULD first send a GOAWAY
+  // frame (Section 6.8) with the stream identifier of the last stream that it
+  // successfully received from its peer.
+  // The GOAWAY frame includes an error code that indicates the type of error"
+  // The GOAWAY frame is already sent by nghttp2. We emit the error
+  // to liberate the Http2Session to destroy.
   if (nghttp2_is_fatal(lib_error_code) ||
-      lib_error_code == NGHTTP2_ERR_STREAM_CLOSED) {
+      lib_error_code == NGHTTP2_ERR_STREAM_CLOSED ||
+      lib_error_code == NGHTTP2_ERR_PROTO) {
     Environment* env = session->env();
     Isolate* isolate = env->isolate();
     HandleScope scope(isolate);
@@ -1070,7 +1085,6 @@ int Http2Session::OnFrameNotSent(nghttp2_session* handle,
   Debug(session, "frame type %d was not sent, code: %d",
         frame->hd.type, error_code);
 
-  // Do not report if the frame was not sent due to the session closing
   if (error_code == NGHTTP2_ERR_SESSION_CLOSING ||
       error_code == NGHTTP2_ERR_STREAM_CLOSED ||
       error_code == NGHTTP2_ERR_STREAM_CLOSING) {
@@ -1079,7 +1093,15 @@ int Http2Session::OnFrameNotSent(nghttp2_session* handle,
     // to destroy the session completely.
     // Further information see: https://github.com/nodejs/node/issues/35233
     session->DecrefHeaders(frame);
-    return 0;
+    // Currently, nghttp2 doesn't not inform us when is the best
+    // time to call session.close(). It relies on a closing connection
+    // from peer. If that doesn't happen, the nghttp2_session will be
+    // closed but the Http2Session will still be up causing a memory leak.
+    // Therefore, if the GOAWAY frame couldn't be send due to
+    // ERR_SESSION_CLOSING we should force close from our side.
+    if (frame->hd.type != 0x03) {
+      return 0;
+    }
   }
 
   Isolate* isolate = env->isolate();
@@ -1145,12 +1167,15 @@ int Http2Session::OnStreamClose(nghttp2_session* handle,
 // ignore these. If this callback was not provided, nghttp2 would handle
 // invalid headers strictly and would shut down the stream. We are intentionally
 // being more lenient here although we may want to revisit this choice later.
-int Http2Session::OnInvalidHeader(nghttp2_session* session,
+int Http2Session::OnInvalidHeader(nghttp2_session* handle,
                                   const nghttp2_frame* frame,
                                   nghttp2_rcbuf* name,
                                   nghttp2_rcbuf* value,
                                   uint8_t flags,
                                   void* user_data) {
+  Http2Session* session = static_cast<Http2Session*>(user_data);
+  int32_t id = GetFrameID(frame);
+  Debug(session, "invalid header received for stream %d", id);
   // Ignore invalid header fields by default.
   return 0;
 }
@@ -1544,6 +1569,7 @@ void Http2Session::HandlePingFrame(const nghttp2_frame* frame) {
 
 // Called by OnFrameReceived when a complete SETTINGS frame has been received.
 void Http2Session::HandleSettingsFrame(const nghttp2_frame* frame) {
+  Debug(this, "handling settings frame");
   bool ack = frame->hd.flags & NGHTTP2_FLAG_ACK;
   if (!ack) {
     js_fields_->bitfield &= ~(1 << kSessionRemoteSettingsIsUpToDate);
index f4d033efe65707718bc1fba1322b832751452242..456aa1ce10d627038fa305846532f68ffd33a9cd 100644 (file)
@@ -27,4 +27,10 @@ server.listen(0, common.mustCall(() => {
       server.close();
     }));
   }));
+
+  client.on('error', common.expectsError({
+    code: 'ERR_HTTP2_ERROR',
+    name: 'Error',
+    message: 'Protocol error'
+  }));
 }));
diff --git a/test/parallel/test-http2-invalid-last-stream-id.js b/test/parallel/test-http2-invalid-last-stream-id.js
new file mode 100644 (file)
index 0000000..c6e4e78
--- /dev/null
@@ -0,0 +1,77 @@
+// Flags: --expose-internals
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto) common.skip('missing crypto');
+
+const h2 = require('http2');
+const net = require('net');
+const assert = require('assert');
+const { ServerHttp2Session } = require('internal/http2/core');
+
+async function sendInvalidLastStreamId(server) {
+  const client = new net.Socket();
+
+  const address = server.address();
+  if (!common.hasIPv6 && address.family === 'IPv6') {
+    // Necessary to pass CI running inside containers.
+    client.connect(address.port);
+  } else {
+    client.connect(address);
+  }
+
+  client.on('connect', common.mustCall(function() {
+    // HTTP/2 preface
+    client.write(Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n', 'utf8'));
+
+    // Empty SETTINGS frame
+    client.write(Buffer.from([0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]));
+
+    // GOAWAY frame with custom debug message
+    const goAwayFrame = [
+      0x00, 0x00, 0x21, // Length: 33 bytes
+      0x07, // Type: GOAWAY
+      0x00, // Flags
+      0x00, 0x00, 0x00, 0x00, // Stream ID: 0
+      0x00, 0x00, 0x00, 0x01, // Last Stream ID: 1
+      0x00, 0x00, 0x00, 0x00, // Error Code: 0 (No error)
+    ];
+
+    // Add the debug message
+    const debugMessage = 'client transport shutdown';
+    const goAwayBuffer = Buffer.concat([
+      Buffer.from(goAwayFrame),
+      Buffer.from(debugMessage, 'utf8'),
+    ]);
+
+    client.write(goAwayBuffer);
+    client.destroy();
+  }));
+}
+
+const server = h2.createServer();
+
+server.on('error', common.mustNotCall());
+
+server.on(
+  'sessionError',
+  common.mustCall((err, session) => {
+    // When destroying the session, on Windows, we would get ECONNRESET
+    // errors, make sure we take those into account in our tests.
+    if (err.code !== 'ECONNRESET') {
+      assert.strictEqual(err.code, 'ERR_HTTP2_ERROR');
+      assert.strictEqual(err.name, 'Error');
+      assert.strictEqual(err.message, 'Protocol error');
+      assert.strictEqual(session instanceof ServerHttp2Session, true);
+    }
+    session.close();
+    server.close();
+  }),
+);
+
+server.listen(
+  0,
+  common.mustCall(async () => {
+    await sendInvalidLastStreamId(server);
+  }),
+);
index af1cc6f9bc4860de1d31813a647f676ea89e1ce5..15b142ac89b811929917f07eda369329f11cfc76 100644 (file)
@@ -35,9 +35,11 @@ server.listen(0, common.mustCall(() => {
     assert.strictEqual(code, h2.constants.NGHTTP2_FRAME_SIZE_ERROR);
   }));
 
+  // NGHTTP2 will automatically send the NGHTTP2_REFUSED_STREAM with
+  // the GOAWAY frame.
   req.on('error', common.expectsError({
     code: 'ERR_HTTP2_STREAM_ERROR',
     name: 'Error',
-    message: 'Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR'
+    message: 'Stream closed with error code NGHTTP2_REFUSED_STREAM'
   }));
 }));
index df3aefff11e7adb0bf7d826a8918df515140194a..7767dbbc5038149278f4fd956c1d7281d8975c43 100644 (file)
@@ -59,6 +59,9 @@ server.listen(0, common.mustCall(() => {
     'session',
     common.mustCall((session) => {
       assert.strictEqual(session instanceof ServerHttp2Session, true);
+      session.on('close', common.mustCall(() => {
+        server.close();
+      }));
     }),
   );
   server.on(
@@ -80,7 +83,6 @@ server.listen(0, common.mustCall(() => {
       assert.strictEqual(err.name, 'Error');
       assert.strictEqual(err.message, 'Session closed with error code 9');
       assert.strictEqual(session instanceof ServerHttp2Session, true);
-      server.close();
     }),
   );
 
diff --git a/test/parallel/test-http2-premature-close.js b/test/parallel/test-http2-premature-close.js
new file mode 100644 (file)
index 0000000..a9b08f5
--- /dev/null
@@ -0,0 +1,88 @@
+// Flags: --expose-internals
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto) common.skip('missing crypto');
+
+const h2 = require('http2');
+const net = require('net');
+
+async function requestAndClose(server) {
+  const client = new net.Socket();
+
+  const address = server.address();
+  if (!common.hasIPv6 && address.family === 'IPv6') {
+    // Necessary to pass CI running inside containers.
+    client.connect(address.port);
+  } else {
+    client.connect(address);
+  }
+
+  client.on('connect', common.mustCall(function() {
+    // Send HTTP/2 Preface
+    client.write(Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n', 'utf8'));
+
+    // Send a SETTINGS frame (empty payload)
+    client.write(Buffer.from([0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]));
+
+    const streamId = 1;
+    // Send a valid HEADERS frame
+    const headersFrame = Buffer.concat([
+      Buffer.from([
+        0x00, 0x00, 0x0c, // Length: 12 bytes
+        0x01, // Type: HEADERS
+        0x05, // Flags: END_HEADERS + END_STREAM
+        (streamId >> 24) & 0xFF, // Stream ID: high byte
+        (streamId >> 16) & 0xFF,
+        (streamId >> 8) & 0xFF,
+        streamId & 0xFF, // Stream ID: low byte
+      ]),
+      Buffer.from([
+        0x82, // Indexed Header Field Representation (Predefined ":method: GET")
+        0x84, // Indexed Header Field Representation (Predefined ":path: /")
+        0x86, // Indexed Header Field Representation (Predefined ":scheme: http")
+        0x44, 0x0a, // Custom ":authority: localhost"
+        0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x68, 0x6f, 0x73, 0x74,
+      ]),
+    ]);
+    client.write(headersFrame);
+
+    // Send a valid DATA frame
+    const dataFrame = Buffer.concat([
+      Buffer.from([
+        0x00, 0x00, 0x05, // Length: 5 bytes
+        0x00, // Type: DATA
+        0x00, // Flags: No flags
+        (streamId >> 24) & 0xFF, // Stream ID: high byte
+        (streamId >> 16) & 0xFF,
+        (streamId >> 8) & 0xFF,
+        streamId & 0xFF, // Stream ID: low byte
+      ]),
+      Buffer.from('Hello', 'utf8'), // Data payload
+    ]);
+    client.write(dataFrame);
+
+    // Does not wait for server to reply. Shutdown the socket
+    client.end();
+  }));
+}
+
+const server = h2.createServer();
+
+server.on('error', common.mustNotCall());
+
+server.on(
+  'session',
+  common.mustCall((session) => {
+    session.on('close', common.mustCall(() => {
+      server.close();
+    }));
+  }),
+);
+
+server.listen(
+  0,
+  common.mustCall(async () => {
+    await requestAndClose(server);
+  }),
+);