Skip to content

Commit

Permalink
http2: implement maxSessionMemory
Browse files Browse the repository at this point in the history
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.

Backport-PR-URL: nodejs#18050
PR-URL: nodejs#17967
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
  • Loading branch information
jasnell authored and kjin committed Apr 30, 2018
1 parent 098d052 commit 3a5e97f
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 15 deletions.
27 changes: 27 additions & 0 deletions doc/api/http2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1652,6 +1652,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,
Expand Down Expand Up @@ -1730,6 +1739,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,
Expand Down Expand Up @@ -1813,6 +1831,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,
Expand Down
8 changes: 7 additions & 1 deletion lib/internal/http2/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
55 changes: 44 additions & 11 deletions src/node_http2.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -673,18 +687,21 @@ inline bool Http2Session::CanAddStream() {
size_t maxSize =
std::min(streams_.max_size(), static_cast<size_t>(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
Expand Down Expand Up @@ -1678,7 +1695,7 @@ Http2Stream::Http2Stream(

Http2Stream::~Http2Stream() {
if (session_ != nullptr) {
session_->RemoveStream(id_);
session_->RemoveStream(this);
session_ = nullptr;
}

Expand Down Expand Up @@ -2008,7 +2025,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;
Expand All @@ -2030,7 +2047,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;
}
Expand Down Expand Up @@ -2174,7 +2194,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);
}
}

Expand All @@ -2197,6 +2217,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
Expand Down Expand Up @@ -2690,6 +2719,7 @@ Http2Session::Http2Ping* Http2Session::PopPing() {
if (!outstanding_pings_.empty()) {
ping = outstanding_pings_.front();
outstanding_pings_.pop();
DecrementCurrentSessionMemory(ping->self_size());
}
return ping;
}
Expand All @@ -2698,6 +2728,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;
}

Expand All @@ -2706,6 +2737,7 @@ Http2Session::Http2Settings* Http2Session::PopSettings() {
if (!outstanding_settings_.empty()) {
settings = outstanding_settings_.front();
outstanding_settings_.pop();
DecrementCurrentSessionMemory(settings->self_size());
}
return settings;
}
Expand All @@ -2714,6 +2746,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;
}

Expand Down
45 changes: 44 additions & 1 deletion src/node_http2.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -500,8 +503,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;
Expand Down Expand Up @@ -628,6 +640,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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<int32_t, Http2Stream*> streams_;

Expand Down
1 change: 1 addition & 0 deletions src/node_http2_state.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
7 changes: 5 additions & 2 deletions test/parallel/test-http2-util-update-options-buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -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];

Expand Down
44 changes: 44 additions & 0 deletions test/sequential/test-http2-max-session-memory.js
Original file line number Diff line number Diff line change
@@ -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());
}
}));

0 comments on commit 3a5e97f

Please sign in to comment.