Memory safety / Resource leak in HTTP/2 session due to flow-control overflow
Description
This commit fixes a memory leak/resource leak in Node.js HTTP/2 by ensuring Http2Session is destroyed when a GOAWAY is caused by an HTTP/2 flow-control overflow. Specifically, nghttp2 can return NGHTTP2_ERR_FLOW_CONTROL in OnInvalidFrame for a WINDOW_UPDATE that pushes the connection-level flow control window past 2^31-1. Previously, OnInvalidFrame only treated NGHTTP2_ERR_STREAM_CLOSED and NGHTTP2_ERR_PROTO as fatal, which could leave the Http2Session reachable but not properly destroyed after such a GOAWAY, leading to a memory leak. The patch adds NGHTTP2_ERR_FLOW_CONTROL to the fatal-condition check and destroys the session in that case. A regression test that reproduces the scenario (WINDOW_UPDATE on stream 0 causing overflow) is added to confirm the fix.
Proof of Concept
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const http2 = require('http2');
const net = require('net');
// Regression test: a connection-level WINDOW_UPDATE that causes the flow
// control window to exceed 2^31-1 must destroy the Http2Session (not leak it).
//
// nghttp2 responds with GOAWAY(FLOW_CONTROL_ERROR) internally but previously
// Node's OnInvalidFrame callback only propagated errors for
// NGHTTP2_ERR_STREAM_CLOSED and NGHTTP2_ERR_PROTO. The missing
// NGHTTP2_ERR_FLOW_CONTROL case left the session unreachable after the GOAWAY,
// causing a memory leak.
const server = http2.createServer();
server.on('session', common.mustCall((session) => {
session.on('error', common.mustCall());
session.on('close', common.mustCall(() => server.close()));
}));
server.listen(0, common.mustCall(() => {
const conn = net.connect({
port: server.address().port,
allowHalfOpen: true,
});
// HTTP/2 client connection preface.
conn.write('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n');
// Empty SETTINGS frame (9-byte header, 0-byte payload).
const settingsFrame = Buffer.alloc(9);
settingsFrame[3] = 0x04; // type: SETTINGS
conn.write(settingsFrame);
let inbuf = Buffer.alloc(0);
let state = 'settingsHeader';
let settingsFrameLength;
conn.on('data', (chunk) => {
inbuf = Buffer.concat([inbuf, chunk]);
switch (state) {
case 'settingsHeader':
if (inbuf.length < 9) return;
settingsFrameLength = inbuf.readUIntBE(0, 3);
inbuf = inbuf.slice(9);
state = 'readingSettings';
// Fallthrough
case 'readingSettings': {
if (inbuf.length < settingsFrameLength) return;
inbuf = inbuf.slice(settingsFrameLength);
state = 'done';
// ACK the server SETTINGS.
const ack = Buffer.alloc(9);
ack[3] = 0x04; // type: SETTINGS
ack[4] = 0x01; // flag: ACK
conn.write(ack);
// WINDOW_UPDATE on stream 0 (connection level) with increment 2^31-1.
// Default connection window is 65535, so the new total would be
// 65535 + 2147483647 = 2147549182 > 2^31-1, triggering
// NGHTTP2_ERR_FLOW_CONTROL inside nghttp2.
const windowUpdate = Buffer.alloc(13);
windowUpdate.writeUIntBE(4, 0, 3); // length = 4
windowUpdate[3] = 0x08; // type: WINDOW_UPDATE
windowUpdate[4] = 0x00; // flags: none
windowUpdate.writeUIntBE(0, 5, 4); // stream id: 0
windowUpdate.writeUIntBE(0x7FFFFFFF, 9, 4); // increment: 2^31-1
conn.write(windowUpdate);
}
}
});
// The server must close the connection after sending GOAWAY.
conn.on('end', common.mustCall(() => conn.end()));
conn.on('close', common.mustCall());
}));
Commit Details
Author: RafaelGSS
Date: 2026-03-11 14:22 UTC
Message:
src: handle NGHTTP2_ERR_FLOW_CONTROL error code
Refs: https://hackerone.com/reports/3531737
PR-URL: https://github.com/nodejs-private/node-private/pull/832
CVE-ID: CVE-2026-21714
Triage Assessment
Vulnerability Type: Memory safety / Resource leak
Confidence: HIGH
Reasoning:
Commit adds handling for NGHTTP2_ERR_FLOW_CONTROL in Http2Session::OnInvalidFrame to ensure the session is destroyed when a GOAWAY is due to flow-control overflow. This prevents a memory leak where the Http2Session could become unreachable after a GOAWAY, which is a security-relevant resource leak. A regression test is added to reproduce the scenario, aligning with CVE-2026-21714.
Verification Assessment
Vulnerability Type: Memory safety / Resource leak in HTTP/2 session due to flow-control overflow
Confidence: HIGH
Affected Versions: < 25.9.0
Code Diff
diff --git a/src/node_http2.cc b/src/node_http2.cc
index e6e7329dc78216..084a4773673e5b 100644
--- a/src/node_http2.cc
+++ b/src/node_http2.cc
@@ -1154,8 +1154,14 @@ int Http2Session::OnInvalidFrame(nghttp2_session* handle,
// 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.
+ //
+ // ERR_FLOW_CONTROL: A WINDOW_UPDATE on stream 0 pushed the connection-level
+ // flow control window past 2^31-1. nghttp2 sends GOAWAY internally but
+ // without propagating this error the Http2Session would never be destroyed,
+ // causing a memory leak.
if (nghttp2_is_fatal(lib_error_code) ||
lib_error_code == NGHTTP2_ERR_STREAM_CLOSED ||
+ lib_error_code == NGHTTP2_ERR_FLOW_CONTROL ||
lib_error_code == NGHTTP2_ERR_PROTO) {
Environment* env = session->env();
Isolate* isolate = env->isolate();
diff --git a/test/parallel/test-http2-window-update-overflow.js b/test/parallel/test-http2-window-update-overflow.js
new file mode 100644
index 00000000000000..41488af9b08fcf
--- /dev/null
+++ b/test/parallel/test-http2-window-update-overflow.js
@@ -0,0 +1,84 @@
+'use strict';
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const http2 = require('http2');
+const net = require('net');
+
+// Regression test: a connection-level WINDOW_UPDATE that causes the flow
+// control window to exceed 2^31-1 must destroy the Http2Session (not leak it).
+//
+// nghttp2 responds with GOAWAY(FLOW_CONTROL_ERROR) internally but previously
+// Node's OnInvalidFrame callback only propagated errors for
+// NGHTTP2_ERR_STREAM_CLOSED and NGHTTP2_ERR_PROTO. The missing
+// NGHTTP2_ERR_FLOW_CONTROL case left the session unreachable after the GOAWAY,
+// causing a memory leak.
+
+const server = http2.createServer();
+
+server.on('session', common.mustCall((session) => {
+ session.on('error', common.mustCall());
+ session.on('close', common.mustCall(() => server.close()));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const conn = net.connect({
+ port: server.address().port,
+ allowHalfOpen: true,
+ });
+
+ // HTTP/2 client connection preface.
+ conn.write('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n');
+
+ // Empty SETTINGS frame (9-byte header, 0-byte payload).
+ const settingsFrame = Buffer.alloc(9);
+ settingsFrame[3] = 0x04; // type: SETTINGS
+ conn.write(settingsFrame);
+
+ let inbuf = Buffer.alloc(0);
+ let state = 'settingsHeader';
+ let settingsFrameLength;
+
+ conn.on('data', (chunk) => {
+ inbuf = Buffer.concat([inbuf, chunk]);
+
+ switch (state) {
+ case 'settingsHeader':
+ if (inbuf.length < 9) return;
+ settingsFrameLength = inbuf.readUIntBE(0, 3);
+ inbuf = inbuf.slice(9);
+ state = 'readingSettings';
+ // Fallthrough
+ case 'readingSettings': {
+ if (inbuf.length < settingsFrameLength) return;
+ inbuf = inbuf.slice(settingsFrameLength);
+ state = 'done';
+
+ // ACK the server SETTINGS.
+ const ack = Buffer.alloc(9);
+ ack[3] = 0x04; // type: SETTINGS
+ ack[4] = 0x01; // flag: ACK
+ conn.write(ack);
+
+ // WINDOW_UPDATE on stream 0 (connection level) with increment 2^31-1.
+ // Default connection window is 65535, so the new total would be
+ // 65535 + 2147483647 = 2147549182 > 2^31-1, triggering
+ // NGHTTP2_ERR_FLOW_CONTROL inside nghttp2.
+ const windowUpdate = Buffer.alloc(13);
+ windowUpdate.writeUIntBE(4, 0, 3); // length = 4
+ windowUpdate[3] = 0x08; // type: WINDOW_UPDATE
+ windowUpdate[4] = 0x00; // flags: none
+ windowUpdate.writeUIntBE(0, 5, 4); // stream id: 0
+ windowUpdate.writeUIntBE(0x7FFFFFFF, 9, 4); // increment: 2^31-1
+ conn.write(windowUpdate);
+ }
+ }
+ });
+
+ // The server must close the connection after sending GOAWAY.
+ conn.on('end', common.mustCall(() => conn.end()));
+ conn.on('close', common.mustCall());
+}));