Commit 3f3c59317ec for nodejs
commit 3f3c59317ece53db4b8c8aef19a1d3c6ad5bf654
Author: Matteo Collina <hello@matteocollina.com>
Date: Tue Oct 6 14:47:52 2026 +0000
http2: charge inbound DATA retained by streams to maxSessionMemory
Inbound DATA frames delivered to JS while a stream is paused are
retained in the stream's readable buffer until the application reads
them. These were not counted against the session's maxSessionMemory, so
a peer could send many tiny DATA frames on a stream the application is
not draining and make the session retain an unbounded amount of memory,
in proportion to the number of frames rather than the bytes sent.
Charge each such DATA chunk (plus a per-chunk overhead comparable to the
Buffer view bookkeeping) against maxSessionMemory, and release it when
the stream resumes reading (ReadStart) or is destroyed (RemoveStream).
Add connection-level backpressure so the session stops reading from the
socket once it can no longer afford the inbound queued data, resuming
when the application consumes it.
Refs: https://hackerone.com/reports/4089663
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs-private/node-private/pull/1000
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
CVE-ID: CVE-2026-123868
diff --git a/doc/api/http2.md b/doc/api/http2.md
index def3c66d7c5..ecaefb0e990 100644
--- a/doc/api/http2.md
+++ b/doc/api/http2.md
@@ -2928,9 +2928,12 @@ changes:
limit to be exceeded, but new `Http2Stream` instances will be rejected
while this limit is exceeded. The current number of `Http2Stream` sessions,
the current memory use of the header compression tables, header blocks
- retained by open streams, current data queued to be sent, and
- unacknowledged `PING` and `SETTINGS` frames are all counted towards the
- current limit. **Default:** `10`.
+ retained by open streams, current data queued to be sent, inbound DATA
+ frames retained by undrained streams, and unacknowledged `PING` and
+ `SETTINGS` frames are all counted towards the current limit. When the limit
+ is exceeded the session stops reading from the socket (applying
+ backpressure) until the application consumes the queued data. **Default:**
+ `10`.
* `maxHeaderListPairs` {number} Sets the maximum number of header entries.
This is similar to [`server.maxHeadersCount`][] or
[`request.maxHeadersCount`][] in the `node:http` module. The minimum value
@@ -3160,9 +3163,12 @@ changes:
limit to be exceeded, but new `Http2Stream` instances will be rejected
while this limit is exceeded. The current number of `Http2Stream` sessions,
the current memory use of the header compression tables, header blocks
- retained by open streams, current data queued to be sent, and
- unacknowledged `PING` and `SETTINGS` frames are all counted towards the
- current limit. **Default:** `10`.
+ retained by open streams, current data queued to be sent, inbound DATA
+ frames retained by undrained streams, and unacknowledged `PING`
+ and `SETTINGS` frames are all counted towards the current limit. When the
+ limit is exceeded the session stops reading from the socket (applying
+ backpressure) until the application consumes the queued data. **Default:**
+ `10`.
* `maxHeaderListPairs` {number} Sets the maximum number of header entries.
This is similar to [`server.maxHeadersCount`][] or
[`request.maxHeadersCount`][] in the `node:http` module. The minimum value
@@ -3352,9 +3358,12 @@ changes:
limit to be exceeded, but new `Http2Stream` instances will be rejected
while this limit is exceeded. The current number of `Http2Stream` sessions,
the current memory use of the header compression tables, header blocks
- retained by open streams, current data queued to be sent, and
+ retained by open streams, current data queued to be sent, inbound DATA
+ frames retained by undrained streams, and
unacknowledged `PING` and `SETTINGS` frames are all counted towards the
- current limit. For received headers on locally initiated streams, including
+ current limit. When the limit is exceeded the session stops reading from the
+ socket (applying backpressure) until the application consumes the queued data.
+ For received headers on locally initiated streams, including
informational responses and trailers, queued outbound data across the session
is excluded from the memory check. Retained headers still count towards the
limit. This exception does not apply to server push streams.
diff --git a/src/node_http2.cc b/src/node_http2.cc
index 34eb932c0da..6b63f06150a 100644
--- a/src/node_http2.cc
+++ b/src/node_http2.cc
@@ -205,6 +205,10 @@ Http2Options::Http2Options(Http2State* http2_state, SessionType type) {
// this is a credit based system. Existing streams may cause the limit
// to be temporarily exceeded but once over the limit, new streams cannot
// created.
+ // Inbound DATA that is queued in the readable buffer of an undrained stream
+ // also counts against this limit, and when the limit is reached the session
+ // stops reading from the socket (applying backpressure) until the
+ // application consumes the data.
// Important: The maxSessionMemory option in javascript is expressed in
// terms of MB increments (i.e. the value 1 == 1 MB)
if (flags & (1 << IDX_OPTIONS_MAX_SESSION_MEMORY))
@@ -965,8 +969,18 @@ BaseObjectPtr<Http2Stream> Http2Session::RemoveStream(int32_t id) {
DecrementCurrentSessionMemory(stream->retained_headers_length_);
stream->retained_headers_length_ = 0;
}
+ if (stream->inbound_consumed_data_while_paused_memory_ > 0) {
+ DecrementCurrentSessionMemory(
+ stream->inbound_consumed_data_while_paused_memory_);
+ inbound_queued_data_memory_ -=
+ stream->inbound_consumed_data_while_paused_memory_;
+ stream->inbound_consumed_data_while_paused_memory_ = 0;
+ }
DecrementCurrentSessionMemory(sizeof(*stream));
}
+ // Removing the stream may have freed inbound data memory that was keeping
+ // the session from reading; resume if it now can make progress.
+ MaybeStartReading();
return stream;
}
@@ -1477,11 +1491,23 @@ int Http2Session::OnDataChunkReceived(nghttp2_session* handle,
// If the stream owner (e.g. the JS Http2Stream) wants more data, just
// tell nghttp2 that all data has been consumed. Otherwise, defer until
// more data is being requested.
- if (stream->is_reading())
+ if (stream->is_reading()) {
nghttp2_session_consume_stream(handle, id, avail);
- else
+ } else {
stream->inbound_consumed_data_while_paused_ += avail;
+ // The Buffer handed to JS may stay alive in the readable queue for as
+ // long as the stream remains paused. Charge it (including a per-chunk
+ // overhead for the Buffer view) against maxSessionMemory so that a peer
+ // cannot make the session retain an unbounded amount of memory by
+ // sending many tiny DATA frames.
+ uint64_t memory =
+ static_cast<uint64_t>(avail) + kHttp2SessionDataChunkOverhead;
+ stream->inbound_consumed_data_while_paused_memory_ += memory;
+ session->inbound_queued_data_memory_ += memory;
+ session->IncrementCurrentSessionMemory(memory);
+ }
+
// If we have a gathered a lot of data for output, try sending it now.
if (session->outgoing_length_ > 4096 ||
stream->available_outbound_length_ > 4096) {
@@ -1931,12 +1957,30 @@ void Http2Session::MaybeStopReading() {
if (is_reading_stopped() || is_closing()) return;
int want_read = nghttp2_session_want_read(session_.get());
Debug(this, "wants read? %d", want_read);
- if (want_read == 0) {
+ // Stop reading if nghttp2 does not want more data, a write is in progress,
+ // or the session is already holding more inbound DATA than its memory budget
+ // allows. The last condition bounds the amount of memory a peer can make the
+ // session retain by sending many small DATA frames on undrained streams.
+ if (want_read == 0 || inbound_queued_data_memory_ >= max_session_memory_) {
set_reading_stopped();
stream_->ReadStop();
}
}
+void Http2Session::MaybeStartReading() {
+ // Restart reading once the session can make progress: nghttp2 wants more
+ // data, no write is in flight, and the inbound DATA queued while streams
+ // were paused no longer exceeds the session memory budget.
+ if (!is_reading_stopped() || is_closing() || is_destroyed()) return;
+ int want_read = nghttp2_session_want_read(session_.get());
+ Debug(this, "want read? %d", want_read);
+ if (want_read != 0 && !is_write_in_progress() &&
+ inbound_queued_data_memory_ < max_session_memory_) {
+ set_reading_stopped(false);
+ stream_->ReadStart();
+ }
+}
+
// Unset the sending state, finish up all current writes, and reset
// storage for data and metadata that was associated with these writes.
void Http2Session::ClearOutgoing(int status) {
@@ -2726,6 +2770,21 @@ int Http2Stream::ReadStart() {
inbound_consumed_data_while_paused_);
inbound_consumed_data_while_paused_ = 0;
+ // The data that was queued while paused is now being consumed by the
+ // application, so release the memory charged against maxSessionMemory.
+ if (inbound_consumed_data_while_paused_memory_ > 0) {
+ session_->DecrementCurrentSessionMemory(
+ inbound_consumed_data_while_paused_memory_);
+ session_->DecrementInboundQueuedDataMemory(
+ inbound_consumed_data_while_paused_memory_);
+ inbound_consumed_data_while_paused_memory_ = 0;
+ }
+
+ // The session may have stopped reading because the memory limit was
+ // reached; now that the application is consuming data, it may be able to
+ // make progress again.
+ session_->MaybeStartReading();
+
return 0;
}
diff --git a/src/node_http2.h b/src/node_http2.h
index 79837afcb76..24bc6bb128e 100644
--- a/src/node_http2.h
+++ b/src/node_http2.h
@@ -39,6 +39,14 @@ constexpr size_t kDefaultMaxSettings = 10;
// Default maximum total memory cap for Http2Session.
constexpr uint64_t kDefaultMaxSessionMemory = 10000000;
+// Per-DATA-chunk bookkeeping overhead charged against maxSessionMemory. DATA
+// frames delivered to JS are turned into Buffer views that stay alive in the
+// readable queue while a stream is paused. The overhead accounts for the
+// per-chunk view (and related) bookkeeping that is not captured by the payload
+// length alone, so that a peer sending many tiny DATA frames cannot make a
+// session retain an unbounded amount of memory.
+constexpr size_t kHttp2SessionDataChunkOverhead = 128;
+
// Default connection-level flow control window (32MB) to improve throughput
// on high-latency connections. See https://github.com/nodejs/node/issues/38426
constexpr int32_t kDefaultConnectionWindowSize = 33554432;
@@ -556,6 +564,12 @@ class Http2Stream : public AsyncWrap,
// backpressure handling.
size_t inbound_consumed_data_while_paused_ = 0;
+ // The amount of memory charged against maxSessionMemory_ for DATA frames
+ // that were handed to JS while the stream was paused and therefore may still
+ // be retained in the readable queue. Released when the stream resumes
+ // reading (ReadStart) or is destroyed.
+ uint64_t inbound_consumed_data_while_paused_memory_ = 0;
+
// Outbound Data... This is the data written by the JS layer that is
// waiting to be written out to the socket.
std::queue<NgHttp2StreamWrite> queue_;
@@ -722,6 +736,10 @@ class Http2Session : public AsyncWrap,
// Stop reading if nghttp2 doesn't want to anymore.
void MaybeStopReading();
+ // Restart reading if the session was stopped because of the memory limit and
+ // can now make progress.
+ void MaybeStartReading();
+
// Returns pointer to the stream, or nullptr if stream does not exist
BaseObjectPtr<Http2Stream> FindStream(int32_t id);
@@ -815,6 +833,14 @@ class Http2Session : public AsyncWrap,
current_session_memory_ -= amount;
}
+ // Called by a stream when it stops holding inbound DATA while paused, to
+ // keep inbound_queued_data_memory_ (which drives connection-level
+ // backpressure) in sync. The caller is responsible for also calling
+ // DecrementCurrentSessionMemory for the same amount.
+ void DecrementInboundQueuedDataMemory(uint64_t amount) {
+ inbound_queued_data_memory_ -= amount;
+ }
+
void UpdateLocalCustomSettings(size_t count_,
nghttp2_settings_entry* entries_);
@@ -994,6 +1020,11 @@ class Http2Session : public AsyncWrap,
uint64_t current_session_memory_ = 0;
// Part of current_session_memory_: DATA not yet consumed by nghttp2.
uint64_t queued_outbound_memory_ = 0;
+ // The portion of current_session_memory_ that is held by inbound DATA frames
+ // queued in stream readable buffers while the streams are paused. Used to
+ // apply connection-level backpressure (stop reading) once the session cannot
+ // afford to buffer more.
+ uint64_t inbound_queued_data_memory_ = 0;
// The amount of memory allocated by nghttp2 internals
uint64_t current_nghttp2_memory_ = 0;
diff --git a/test/parallel/test-http2-max-session-memory-stalled-data.js b/test/parallel/test-http2-max-session-memory-stalled-data.js
new file mode 100644
index 00000000000..e6fcfea6029
--- /dev/null
+++ b/test/parallel/test-http2-max-session-memory-stalled-data.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('assert');
+const http2 = require('http2');
+const net = require('net');
+
+// Regression test: inbound DATA frames handed to JS on an undrained stream must
+// count against maxSessionMemory. Before the fix a peer could send many tiny
+// DATA frames on a stream the application never reads, and the session would
+// keep reading (and buffering) them. Because each frame becomes a Buffer view in
+// the readable queue, memory grew in proportion to the *number* of frames rather
+// than the bytes sent, so a single stream could make the process retain hundreds
+// of MB. With the fix the session charges the queued data against
+// maxSessionMemory and stops reading once it can no longer afford to buffer
+// more, keeping the readable buffer bounded.
+const maxSessionMemory = 1;
+const chunkSize = 1;
+const totalFrames = 200000;
+
+let peakReadableLength = 0;
+
+function frame(type, flags, id, payload) {
+ 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]);
+}
+
+let session = null;
+let probe = null;
+
+const server = http2.createServer({ maxSessionMemory });
+server.on('session', (s) => {
+ session = s;
+ s.on('error', () => {});
+});
+server.on('stream', (stream) => {
+ stream.on('error', () => {});
+ // The application never reads the request body, so the stream stays
+ // undrained. Record the largest amount of data the readable buffer ever holds;
+ // with the fix that stays bounded by maxSessionMemory instead of growing to
+ // match the number of frames the peer sends.
+ probe = setInterval(() => {
+ peakReadableLength = Math.max(peakReadableLength, stream.readableLength);
+ }, 10);
+});
+
+server.listen(0, common.mustCall(() => {
+ const sock = net.connect(server.address().port, '127.0.0.1');
+ sock.on('error', () => {});
+
+ sock.on('connect', () => {
+ sock.write(Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'));
+ // Client SETTINGS.
+ sock.write(frame(4, 0, 0, Buffer.alloc(0)));
+ });
+
+ let settingsAcked = false;
+ sock.on('data', (chunk) => {
+ if (!settingsAcked && chunk.length >= 9 && chunk[3] === 4) {
+ settingsAcked = true;
+ // ACK the server's SETTINGS.
+ sock.write(Buffer.from([0, 0, 0, 4, 1, 0, 0, 0, 0]));
+ // Open one stream (END_HEADERS) and spray 1-byte DATA frames at it.
+ const hpack = Buffer.from([0x83, 0x86, 0x84, 0x01, 0x01, 0x61]);
+ sock.write(frame(1, 4, 1, hpack));
+ const payload = Buffer.alloc(chunkSize, 0x61);
+ const parts = [];
+ for (let i = 0; i < totalFrames; i++) {
+ parts.push(frame(0, 0, 1, payload));
+ }
+ sock.write(Buffer.concat(parts));
+ }
+ });
+
+ setTimeout(common.mustCall(() => {
+ if (probe) clearInterval(probe);
+ sock.destroy();
+ if (session) session.destroy();
+ server.close();
+ // The session must not let one undrained stream buffer every frame it was
+ // sent. Before the fix the readable buffer grew to totalFrames; with the fix
+ // the backpressure keeps it bounded well below half of that.
+ assert.ok(peakReadableLength < totalFrames / 2,
+ `expected backpressure to bound readableLength, got ${peakReadableLength}`);
+ }), 4000);
+}));