From ae80dc88a17e6af9f274ed23ccf9a76c488009ff Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 3 Jan 2018 11:15:57 -0800 Subject: [PATCH] http2: implement maxSessionMemory The maxSessionMemory is a cap for the amount of memory an Http2Session is permitted to consume. If exceeded, new `Http2Stream` sessions will be rejected with an `ENHANCE_YOUR_CALM` error and existing `Http2Stream` instances that are still receiving headers will be terminated with an `ENHANCE_YOUR_CALM` error. --- doc/api/http2.md | 27 +++++++++ lib/internal/http2/util.js | 8 ++- src/node_http2.cc | 55 +++++++++++++++---- src/node_http2.h | 45 ++++++++++++++- src/node_http2_state.h | 1 + .../test-http2-util-update-options-buffer.js | 7 ++- .../test-http2-max-session-memory.js | 44 +++++++++++++++ 7 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 test/sequential/test-http2-max-session-memory.js diff --git a/doc/api/http2.md b/doc/api/http2.md index 4a071272fa85b7..f87824e932191d 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -1633,6 +1633,15 @@ changes: * `options` {Object} * `maxDeflateDynamicTableSize` {number} Sets the maximum dynamic table size for deflating header fields. **Default:** `4Kib` + * `maxSessionMemory`{number} Sets the maximum memory that the `Http2Session` + is permitted to use. The value is expressed in terms of number of megabytes, + e.g. `1` equal 1 megabyte. The minimum value allowed is `1`. **Default:** + `10`. This is a credit based limit, existing `Http2Stream`s may cause this + 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, current data + queued to be sent, and unacknowledged PING and SETTINGS frames are all + counted towards the current limit. * `maxHeaderListPairs` {number} Sets the maximum number of header entries. **Default:** `128`. The minimum value is `4`. * `maxOutstandingPings` {number} Sets the maximum number of outstanding, @@ -1711,6 +1720,15 @@ changes: `false`. See the [`'unknownProtocol'`][] event. See [ALPN negotiation][]. * `maxDeflateDynamicTableSize` {number} Sets the maximum dynamic table size for deflating header fields. **Default:** `4Kib` + * `maxSessionMemory`{number} Sets the maximum memory that the `Http2Session` + is permitted to use. The value is expressed in terms of number of megabytes, + e.g. `1` equal 1 megabyte. The minimum value allowed is `1`. **Default:** + `10`. This is a credit based limit, existing `Http2Stream`s may cause this + 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, current data + queued to be sent, and unacknowledged PING and SETTINGS frames are all + counted towards the current limit. * `maxHeaderListPairs` {number} Sets the maximum number of header entries. **Default:** `128`. The minimum value is `4`. * `maxOutstandingPings` {number} Sets the maximum number of outstanding, @@ -1794,6 +1812,15 @@ changes: * `options` {Object} * `maxDeflateDynamicTableSize` {number} Sets the maximum dynamic table size for deflating header fields. **Default:** `4Kib` + * `maxSessionMemory`{number} Sets the maximum memory that the `Http2Session` + is permitted to use. The value is expressed in terms of number of megabytes, + e.g. `1` equal 1 megabyte. The minimum value allowed is `1`. **Default:** + `10`. This is a credit based limit, existing `Http2Stream`s may cause this + 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, current data + queued to be sent, and unacknowledged PING and SETTINGS frames are all + counted towards the current limit. * `maxHeaderListPairs` {number} Sets the maximum number of header entries. **Default:** `128`. The minimum value is `1`. * `maxOutstandingPings` {number} Sets the maximum number of outstanding, diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index fffea10ab6f819..1411ab7cf72ad7 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -175,7 +175,8 @@ const IDX_OPTIONS_PADDING_STRATEGY = 4; const IDX_OPTIONS_MAX_HEADER_LIST_PAIRS = 5; const IDX_OPTIONS_MAX_OUTSTANDING_PINGS = 6; const IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS = 7; -const IDX_OPTIONS_FLAGS = 8; +const IDX_OPTIONS_MAX_SESSION_MEMORY = 8; +const IDX_OPTIONS_FLAGS = 9; function updateOptionsBuffer(options) { var flags = 0; @@ -219,6 +220,11 @@ function updateOptionsBuffer(options) { optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS] = Math.max(1, options.maxOutstandingSettings); } + if (typeof options.maxSessionMemory === 'number') { + flags |= (1 << IDX_OPTIONS_MAX_SESSION_MEMORY); + optionsBuffer[IDX_OPTIONS_MAX_SESSION_MEMORY] = + Math.max(1, options.maxSessionMemory); + } optionsBuffer[IDX_OPTIONS_FLAGS] = flags; } diff --git a/src/node_http2.cc b/src/node_http2.cc index 9caee6d40183a7..85bdde9b92160f 100644 --- a/src/node_http2.cc +++ b/src/node_http2.cc @@ -174,6 +174,18 @@ Http2Options::Http2Options(Environment* env) { if (flags & (1 << IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS)) { SetMaxOutstandingSettings(buffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS]); } + + // The HTTP2 specification places no limits on the amount of memory + // that a session can consume. In order to prevent abuse, we place a + // cap on the amount of memory a session can consume at any given time. + // 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. + // 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)) { + SetMaxSessionMemory(buffer[IDX_OPTIONS_MAX_SESSION_MEMORY] * 1e6); + } } void Http2Session::Http2Settings::Init() { @@ -482,11 +494,13 @@ Http2Session::Http2Session(Environment* env, // Capture the configuration options for this session Http2Options opts(env); - int32_t maxHeaderPairs = opts.GetMaxHeaderPairs(); + max_session_memory_ = opts.GetMaxSessionMemory(); + + uint32_t maxHeaderPairs = opts.GetMaxHeaderPairs(); max_header_pairs_ = type == NGHTTP2_SESSION_SERVER - ? std::max(maxHeaderPairs, 4) // minimum # of request headers - : std::max(maxHeaderPairs, 1); // minimum # of response headers + ? std::max(maxHeaderPairs, 4U) // minimum # of request headers + : std::max(maxHeaderPairs, 1U); // minimum # of response headers max_outstanding_pings_ = opts.GetMaxOutstandingPings(); max_outstanding_settings_ = opts.GetMaxOutstandingSettings(); @@ -672,18 +686,21 @@ inline bool Http2Session::CanAddStream() { size_t maxSize = std::min(streams_.max_size(), static_cast(maxConcurrentStreams)); // We can add a new stream so long as we are less than the current - // maximum on concurrent streams - return streams_.size() < maxSize; + // maximum on concurrent streams and there's enough available memory + return streams_.size() < maxSize && + IsAvailableSessionMemory(sizeof(Http2Stream)); } inline void Http2Session::AddStream(Http2Stream* stream) { CHECK_GE(++statistics_.stream_count, 0); streams_[stream->id()] = stream; + IncrementCurrentSessionMemory(stream->self_size()); } -inline void Http2Session::RemoveStream(int32_t id) { - streams_.erase(id); +inline void Http2Session::RemoveStream(Http2Stream* stream) { + streams_.erase(stream->id()); + DecrementCurrentSessionMemory(stream->self_size()); } // Used as one of the Padding Strategy functions. Will attempt to ensure @@ -1677,7 +1694,7 @@ Http2Stream::Http2Stream( Http2Stream::~Http2Stream() { if (session_ != nullptr) { - session_->RemoveStream(id_); + session_->RemoveStream(this); session_ = nullptr; } @@ -2007,7 +2024,7 @@ inline int Http2Stream::DoWrite(WriteWrap* req_wrap, i == nbufs - 1 ? req_wrap : nullptr, bufs[i] }); - available_outbound_length_ += bufs[i].len; + IncrementAvailableOutboundLength(bufs[i].len); } CHECK_NE(nghttp2_session_resume_data(**session_, id_), NGHTTP2_ERR_NOMEM); return 0; @@ -2029,7 +2046,10 @@ inline bool Http2Stream::AddHeader(nghttp2_rcbuf* name, if (this->statistics_.first_header == 0) this->statistics_.first_header = uv_hrtime(); size_t length = GetBufferLength(name) + GetBufferLength(value) + 32; - if (current_headers_.size() == max_header_pairs_ || + // A header can only be added if we have not exceeded the maximum number + // of headers and the session has memory available for it. + if (!session_->IsAvailableSessionMemory(length) || + current_headers_.size() == max_header_pairs_ || current_headers_length_ + length > max_header_length_) { return false; } @@ -2173,7 +2193,7 @@ ssize_t Http2Stream::Provider::Stream::OnRead(nghttp2_session* handle, // Just return the length, let Http2Session::OnSendData take care of // actually taking the buffers out of the queue. *flags |= NGHTTP2_DATA_FLAG_NO_COPY; - stream->available_outbound_length_ -= amount; + stream->DecrementAvailableOutboundLength(amount); } } @@ -2196,6 +2216,15 @@ ssize_t Http2Stream::Provider::Stream::OnRead(nghttp2_session* handle, return amount; } +inline void Http2Stream::IncrementAvailableOutboundLength(size_t amount) { + available_outbound_length_ += amount; + session_->IncrementCurrentSessionMemory(amount); +} + +inline void Http2Stream::DecrementAvailableOutboundLength(size_t amount) { + available_outbound_length_ -= amount; + session_->DecrementCurrentSessionMemory(amount); +} // Implementation of the JavaScript API @@ -2689,6 +2718,7 @@ Http2Session::Http2Ping* Http2Session::PopPing() { if (!outstanding_pings_.empty()) { ping = outstanding_pings_.front(); outstanding_pings_.pop(); + DecrementCurrentSessionMemory(ping->self_size()); } return ping; } @@ -2697,6 +2727,7 @@ bool Http2Session::AddPing(Http2Session::Http2Ping* ping) { if (outstanding_pings_.size() == max_outstanding_pings_) return false; outstanding_pings_.push(ping); + IncrementCurrentSessionMemory(ping->self_size()); return true; } @@ -2705,6 +2736,7 @@ Http2Session::Http2Settings* Http2Session::PopSettings() { if (!outstanding_settings_.empty()) { settings = outstanding_settings_.front(); outstanding_settings_.pop(); + DecrementCurrentSessionMemory(settings->self_size()); } return settings; } @@ -2713,6 +2745,7 @@ bool Http2Session::AddSettings(Http2Session::Http2Settings* settings) { if (outstanding_settings_.size() == max_outstanding_settings_) return false; outstanding_settings_.push(settings); + IncrementCurrentSessionMemory(settings->self_size()); return true; } diff --git a/src/node_http2.h b/src/node_http2.h index f63f8133a4a657..4fc98f0c68a3ba 100644 --- a/src/node_http2.h +++ b/src/node_http2.h @@ -82,6 +82,9 @@ void inline debug_vfprintf(const char* format, ...) { // Also strictly limit the number of outstanding SETTINGS frames a user sends #define DEFAULT_MAX_SETTINGS 10 +// Default maximum total memory cap for Http2Session. +#define DEFAULT_MAX_SESSION_MEMORY 1e7; + // These are the standard HTTP/2 defaults as specified by the RFC #define DEFAULT_SETTINGS_HEADER_TABLE_SIZE 4096 #define DEFAULT_SETTINGS_ENABLE_PUSH 1 @@ -501,8 +504,17 @@ class Http2Options { return max_outstanding_settings_; } + void SetMaxSessionMemory(uint64_t max) { + max_session_memory_ = max; + } + + uint64_t GetMaxSessionMemory() { + return max_session_memory_; + } + private: nghttp2_option* options_; + uint64_t max_session_memory_ = DEFAULT_MAX_SESSION_MEMORY; uint32_t max_header_pairs_ = DEFAULT_MAX_HEADER_LIST_PAIRS; padding_strategy_type padding_strategy_ = PADDING_STRATEGY_NONE; size_t max_outstanding_pings_ = DEFAULT_MAX_PINGS; @@ -629,6 +641,9 @@ class Http2Stream : public AsyncWrap, // Returns the stream identifier for this stream inline int32_t id() const { return id_; } + inline void IncrementAvailableOutboundLength(size_t amount); + inline void DecrementAvailableOutboundLength(size_t amount); + inline bool AddHeader(nghttp2_rcbuf* name, nghttp2_rcbuf* value, uint8_t flags); @@ -848,7 +863,7 @@ class Http2Session : public AsyncWrap { inline void AddStream(Http2Stream* stream); // Removes a stream instance from this session - inline void RemoveStream(int32_t id); + inline void RemoveStream(Http2Stream* stream); // Write data to the session inline ssize_t Write(const uv_buf_t* bufs, size_t nbufs); @@ -906,6 +921,30 @@ class Http2Session : public AsyncWrap { Http2Settings* PopSettings(); bool AddSettings(Http2Settings* settings); + void IncrementCurrentSessionMemory(uint64_t amount) { + current_session_memory_ += amount; + } + + void DecrementCurrentSessionMemory(uint64_t amount) { + current_session_memory_ -= amount; + } + + // Returns the current session memory including the current size of both + // the inflate and deflate hpack headers, the current outbound storage + // queue, and pending writes. + uint64_t GetCurrentSessionMemory() { + uint64_t total = current_session_memory_ + sizeof(Http2Session); + total += nghttp2_session_get_hd_deflate_dynamic_table_size(session_); + total += nghttp2_session_get_hd_inflate_dynamic_table_size(session_); + total += outgoing_storage_.size(); + return total; + } + + // Return true if current_session_memory + amount is less than the max + bool IsAvailableSessionMemory(uint64_t amount) { + return GetCurrentSessionMemory() + amount <= max_session_memory_; + } + struct Statistics { uint64_t start_time; uint64_t end_time; @@ -1035,6 +1074,10 @@ class Http2Session : public AsyncWrap { // The maximum number of header pairs permitted for streams on this session uint32_t max_header_pairs_ = DEFAULT_MAX_HEADER_LIST_PAIRS; + // The maximum amount of memory allocated for this session + uint64_t max_session_memory_ = DEFAULT_MAX_SESSION_MEMORY; + uint64_t current_session_memory_ = 0; + // The collection of active Http2Streams associated with this session std::unordered_map streams_; diff --git a/src/node_http2_state.h b/src/node_http2_state.h index ef8696ce60d8f8..af0740c994e765 100644 --- a/src/node_http2_state.h +++ b/src/node_http2_state.h @@ -50,6 +50,7 @@ namespace http2 { IDX_OPTIONS_MAX_HEADER_LIST_PAIRS, IDX_OPTIONS_MAX_OUTSTANDING_PINGS, IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS, + IDX_OPTIONS_MAX_SESSION_MEMORY, IDX_OPTIONS_FLAGS }; diff --git a/test/parallel/test-http2-util-update-options-buffer.js b/test/parallel/test-http2-util-update-options-buffer.js index 5768ce0204dc8c..6ab8bcff02866e 100644 --- a/test/parallel/test-http2-util-update-options-buffer.js +++ b/test/parallel/test-http2-util-update-options-buffer.js @@ -20,7 +20,8 @@ const IDX_OPTIONS_PADDING_STRATEGY = 4; const IDX_OPTIONS_MAX_HEADER_LIST_PAIRS = 5; const IDX_OPTIONS_MAX_OUTSTANDING_PINGS = 6; const IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS = 7; -const IDX_OPTIONS_FLAGS = 8; +const IDX_OPTIONS_MAX_SESSION_MEMORY = 8; +const IDX_OPTIONS_FLAGS = 9; { updateOptionsBuffer({ @@ -31,7 +32,8 @@ const IDX_OPTIONS_FLAGS = 8; paddingStrategy: 5, maxHeaderListPairs: 6, maxOutstandingPings: 7, - maxOutstandingSettings: 8 + maxOutstandingSettings: 8, + maxSessionMemory: 9 }); strictEqual(optionsBuffer[IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE], 1); @@ -42,6 +44,7 @@ const IDX_OPTIONS_FLAGS = 8; strictEqual(optionsBuffer[IDX_OPTIONS_MAX_HEADER_LIST_PAIRS], 6); strictEqual(optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_PINGS], 7); strictEqual(optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS], 8); + strictEqual(optionsBuffer[IDX_OPTIONS_MAX_SESSION_MEMORY], 9); const flags = optionsBuffer[IDX_OPTIONS_FLAGS]; diff --git a/test/sequential/test-http2-max-session-memory.js b/test/sequential/test-http2-max-session-memory.js new file mode 100644 index 00000000000000..e16000d1261ab0 --- /dev/null +++ b/test/sequential/test-http2-max-session-memory.js @@ -0,0 +1,44 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const http2 = require('http2'); + +// Test that maxSessionMemory Caps work + +const largeBuffer = Buffer.alloc(1e6); + +const server = http2.createServer({ maxSessionMemory: 1 }); + +server.on('stream', common.mustCall((stream) => { + stream.respond(); + stream.end(largeBuffer); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + { + const req = client.request(); + + req.on('response', () => { + // This one should be rejected because the server is over budget + // on the current memory allocation + const req = client.request(); + req.on('error', common.expectsError({ + code: 'ERR_HTTP2_STREAM_ERROR', + type: Error, + message: 'Stream closed with error code 11' + })); + req.on('close', common.mustCall(() => { + server.close(); + client.destroy(); + })); + }); + + req.resume(); + req.on('close', common.mustCall()); + } +}));