Commit 00ba7f05828 for nodejs
commit 00ba7f05828d2be0ca13d6921e4f0852baa25362
Author: Matteo Collina <hello@matteocollina.com>
Date: Thu Oct 29 15:46:35 2026 +0100
http2: release headers when a stream is removed
When a peer sends more header fields than `maxHeaderListPairs`, the
stream is reset and the fields it had already accepted stay in
`current_headers_`. Each of them holds nghttp2 buffers. `RemoveStream()`
adjusted the session memory accounting but kept the headers.
A destroyed stream that still has a write on the socket is not detached
and can be collected after its session. The session destructor only
detaches the streams it still knows about, so the nghttp2 memory held
by the removed stream is still allocated and
`CHECK_EQ(current_nghttp2_memory_, 0)` aborts the process. A client can
trigger this with a single connection.
Clear the headers in `RemoveStream()`, while the session is still
alive, and in the session destructor for the streams that are still
registered, since `Detach()` does not free a stream that is referenced
elsewhere.
Refs: https://hackerone.com/reports/4150893
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs-private/node-private/pull/1026
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Tim Perry <pimterry@gmail.com>
CVE-ID: CVE-2026-123863
diff --git a/src/node_http2.cc b/src/node_http2.cc
index 9a7d700e046..fdf8abc31a3 100644
--- a/src/node_http2.cc
+++ b/src/node_http2.cc
@@ -640,6 +640,9 @@ Http2Session::~Http2Session() {
// Ensure that all `Http2Stream` instances and the memory they hold
// on to are destroyed before the nghttp2 session is.
for (const auto& [id, stream] : streams_) {
+ // Detach() only frees the stream if nothing else holds a strong
+ // reference to it.
+ stream->ClearHeaders();
stream->Detach();
}
streams_.clear();
@@ -949,6 +952,11 @@ BaseObjectPtr<Http2Stream> Http2Session::RemoveStream(int32_t id) {
stream = FindStream(id);
if (stream) {
streams_.erase(id);
+ // The stream can outlive the session, for example while one of its writes
+ // is still on the socket. Headers that were not passed to JS, such as an
+ // oversized block that caused the stream to be reset, hold nghttp2 memory
+ // that has to be released before the session is.
+ stream->ClearHeaders();
if (stream->current_headers_length_ > 0) {
DecrementCurrentSessionMemory(stream->current_headers_length_);
stream->current_headers_length_ = 0;
diff --git a/test/parallel/test-http2-removed-stream-headers-outlive-session.js b/test/parallel/test-http2-removed-stream-headers-outlive-session.js
new file mode 100644
index 00000000000..2dbe66ddbf3
--- /dev/null
+++ b/test/parallel/test-http2-removed-stream-headers-outlive-session.js
@@ -0,0 +1,87 @@
+// Flags: --expose-gc
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+// A stream that is reset because of an oversized header block keeps the
+// headers it had already accepted. If the stream is destroyed while one of
+// its writes is still on the socket, it outlives the session, and the session
+// destructor used to abort because that memory was still allocated.
+
+const http2 = require('http2');
+const net = require('net');
+const { setTimeout: sleep } = require('timers/promises');
+
+// Enough response data to fill the socket buffers on loopback.
+const kStreams = 80;
+
+function frame(type, flags, id, payload = Buffer.alloc(0)) {
+ const header = Buffer.alloc(9);
+ header.writeUIntBE(payload.length, 0, 3);
+ header[3] = type;
+ header[4] = flags;
+ header.writeUInt32BE(id, 5);
+ return Buffer.concat([header, payload]);
+}
+
+// :method POST, :scheme http, :path /, :authority localhost
+const requestHeaders = Buffer.from([
+ 0x83, 0x86, 0x84, 0x01, 0x09, ...Buffer.from('localhost'),
+]);
+
+// More trailer fields than the default maxHeaderListPairs (128).
+const trailers = Buffer.concat(Array.from({ length: 200 }, (_, i) => {
+ const name = `x-${i}`;
+ return Buffer.from([0x00, name.length, ...Buffer.from(name), 0x01, 0x76]);
+}));
+
+const server = http2.createServer(common.mustCall((req, res) => {
+ req.on('error', () => {});
+ res.on('error', () => {});
+ // Respond before the request has ended, with more data than the client
+ // reads, so that the response stays in a pending socket write.
+ res.end(Buffer.alloc(100 * 1024));
+}, kStreams));
+
+server.on('session', common.mustCall((session) => {
+ session.on('error', () => {});
+ session.on('close', common.mustCall(async () => {
+ server.close();
+ // Collect the session before the streams that are still referenced by
+ // their pending writes.
+ for (let i = 0; i < 10; i++) {
+ globalThis.gc();
+ await sleep(10);
+ }
+ }));
+}));
+
+server.listen(0, common.mustCall(async () => {
+ const client = net.connect(server.address().port);
+ client.on('error', () => {});
+ client.pause();
+
+ const settings = Buffer.from([0x00, 0x04, 0x7f, 0xff, 0xff, 0xff]);
+ const windowUpdate = Buffer.alloc(4);
+ windowUpdate.writeUInt32BE(0x7fffffff - 65535);
+ const requests = [];
+ for (let i = 0; i < kStreams; i++)
+ requests.push(frame(1, 0x4, 1 + 2 * i, requestHeaders));
+ client.write(Buffer.concat([
+ Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'),
+ frame(4, 0, 0, settings),
+ frame(8, 0, 0, windowUpdate),
+ ...requests,
+ ]));
+ await sleep(common.platformTimeout(500));
+
+ const blocks = [];
+ for (let i = 0; i < kStreams; i++)
+ blocks.push(frame(1, 0x5, 1 + 2 * i, trailers));
+ client.write(Buffer.concat(blocks));
+ await sleep(common.platformTimeout(200));
+
+ client.resetAndDestroy();
+}));