Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http2: make maximum invalid frames and maximum rejected streams configurable #30534

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions doc/api/http2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,12 @@ error will be thrown.
<!-- YAML
added: v8.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/30534
description: Added `maxSessionRejectedStreams` option with a default of 100.
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/30534
description: Added `maxSessionInvalidFrames` option with a default of 1000.
- version: v13.0.0
pr-url: https://github.com/nodejs/node/pull/29144
description: The `PADDING_STRATEGY_CALLBACK` has been made equivalent to
Expand Down Expand Up @@ -2001,6 +2007,15 @@ changes:
streams for the remote peer as if a `SETTINGS` frame had been received. Will
be overridden if the remote peer sets its own value for
`maxConcurrentStreams`. **Default:** `100`.
* `maxSessionInvalidFrames` {integer} Sets the maximum number of invalid
frames that will be tolerated before the session is closed.
**Default:** `1000`.
* `maxSessionRejectedStreams` {integer} Sets the maximum number of rejected
upon creation streams that will be tolerated before the session is closed.
Each rejection is associated with an `NGHTTP2_ENHANCE_YOUR_CALM`
error that should tell the peer to not open any more streams, continuing
to open streams is therefore regarded as a sign of a misbehaving peer.
**Default:** `100`.
* `settings` {HTTP/2 Settings Object} The initial settings to send to the
remote peer upon connection.
* `Http1IncomingMessage` {http.IncomingMessage} Specifies the
Expand Down Expand Up @@ -2053,6 +2068,12 @@ server.listen(80);
<!-- YAML
added: v8.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/30534
description: Added `maxSessionRejectedStreams` option with a default of 100.
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/30534
description: Added `maxSessionInvalidFrames` option with a default of 1000.
- version: v13.0.0
pr-url: https://github.com/nodejs/node/pull/29144
description: The `PADDING_STRATEGY_CALLBACK` has been made equivalent to
Expand Down Expand Up @@ -2113,6 +2134,15 @@ changes:
streams for the remote peer as if a `SETTINGS` frame had been received. Will
be overridden if the remote peer sets its own value for
`maxConcurrentStreams`. **Default:** `100`.
* `maxSessionInvalidFrames` {integer} Sets the maximum number of invalid
frames that will be tolerated before the session is closed.
**Default:** `1000`.
* `maxSessionRejectedStreams` {integer} Sets the maximum number of rejected
upon creation streams that will be tolerated before the session is closed.
Each rejection is associated with an `NGHTTP2_ENHANCE_YOUR_CALM`
error that should tell the peer to not open any more streams, continuing
to open streams is therefore regarded as a sign of a misbehaving peer.
**Default:** `100`.
* `settings` {HTTP/2 Settings Object} The initial settings to send to the
remote peer upon connection.
* ...: Any [`tls.createServer()`][] options can be provided. For
Expand Down
30 changes: 29 additions & 1 deletion lib/internal/http2/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ const {
},
hideStackFrames
} = require('internal/errors');
const { validateNumber, validateString } = require('internal/validators');
const { validateNumber,
validateString,
validateUint32,
isUint32,
} = require('internal/validators');
const fsPromisesInternal = require('internal/fs/promises');
const { utcDate } = require('internal/http');
const { onServerStream,
Expand Down Expand Up @@ -199,6 +203,8 @@ const {
kBitfield,
kSessionPriorityListenerCount,
kSessionFrameErrorListenerCount,
kSessionMaxInvalidFrames,
kSessionMaxRejectedStreams,
kSessionUint8FieldCount,
kSessionHasRemoteSettingsListeners,
kSessionRemoteSettingsIsUpToDate,
Expand Down Expand Up @@ -937,6 +943,18 @@ function setupHandle(socket, type, options) {
this[kEncrypted] = false;
}

if (isUint32(options.maxSessionInvalidFrames)) {
const uint32 = new Uint32Array(
this[kNativeFields].buffer, kSessionMaxInvalidFrames, 1);
uint32[0] = options.maxSessionInvalidFrames;
}

if (isUint32(options.maxSessionRejectedStreams)) {
const uint32 = new Uint32Array(
this[kNativeFields].buffer, kSessionMaxRejectedStreams, 1);
uint32[0] = options.maxSessionRejectedStreams;
}

const settings = typeof options.settings === 'object' ?
options.settings : {};

Expand Down Expand Up @@ -2768,6 +2786,16 @@ function initializeOptions(options) {
assertIsObject(options.settings, 'options.settings');
options.settings = { ...options.settings };

if (options.maxSessionInvalidFrames !== undefined)
validateUint32(options.maxSessionInvalidFrames, 'maxSessionInvalidFrames');

if (options.maxSessionRejectedStreams !== undefined) {
validateUint32(
options.maxSessionRejectedStreams,
'maxSessionRejectedStreams'
);
}

// Used only with allowHTTP1
options.Http1IncomingMessage = options.Http1IncomingMessage ||
http.IncomingMessage;
Expand Down
29 changes: 19 additions & 10 deletions src/node_http2.cc
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,9 @@ Http2Session::Http2Session(Environment* env,
{
// Make the js_fields_ property accessible to JS land.
Local<ArrayBuffer> ab =
ArrayBuffer::New(env->isolate(), js_fields_, kSessionUint8FieldCount);
ArrayBuffer::New(env->isolate(),
reinterpret_cast<uint8_t*>(&js_fields_),
kSessionUint8FieldCount);
Local<Uint8Array> uint8_arr =
Uint8Array::New(ab, 0, kSessionUint8FieldCount);
USE(wrap->Set(env->context(), env->fields_string(), uint8_arr));
Expand Down Expand Up @@ -918,7 +920,8 @@ int Http2Session::OnBeginHeadersCallback(nghttp2_session* handle,
if (UNLIKELY(!session->CanAddStream() ||
Http2Stream::New(session, id, frame->headers.cat) ==
nullptr)) {
if (session->rejected_stream_count_++ > 100)
if (session->rejected_stream_count_++ >
session->js_fields_.max_rejected_streams)
return NGHTTP2_ERR_CALLBACK_FAILURE;
// Too many concurrent streams being opened
nghttp2_submit_rst_stream(**session, NGHTTP2_FLAG_NONE, id,
Expand Down Expand Up @@ -1009,8 +1012,12 @@ int Http2Session::OnInvalidFrame(nghttp2_session* handle,
void* user_data) {
Http2Session* session = static_cast<Http2Session*>(user_data);

Debug(session, "invalid frame received, code: %d", lib_error_code);
if (session->invalid_frame_count_++ > 1000)
Debug(session,
"invalid frame received (%u/%u), code: %d",
session->invalid_frame_count_,
session->js_fields_.max_invalid_frames,
lib_error_code);
if (session->invalid_frame_count_++ > session->js_fields_.max_invalid_frames)
return 1;

// If the error is fatal or if error code is ERR_STREAM_CLOSED... emit error
Expand Down Expand Up @@ -1046,7 +1053,7 @@ int Http2Session::OnFrameNotSent(nghttp2_session* handle,
if (error_code == NGHTTP2_ERR_SESSION_CLOSING ||
error_code == NGHTTP2_ERR_STREAM_CLOSED ||
error_code == NGHTTP2_ERR_STREAM_CLOSING ||
session->js_fields_[kSessionFrameErrorListenerCount] == 0) {
session->js_fields_.frame_error_listener_count == 0) {
return 0;
}

Expand Down Expand Up @@ -1349,7 +1356,7 @@ void Http2Session::HandleHeadersFrame(const nghttp2_frame* frame) {
// are considered advisory only, so this has no real effect other than to
// simply let user code know that the priority has changed.
void Http2Session::HandlePriorityFrame(const nghttp2_frame* frame) {
if (js_fields_[kSessionPriorityListenerCount] == 0) return;
if (js_fields_.priority_listener_count == 0) return;
Isolate* isolate = env()->isolate();
HandleScope scope(isolate);
Local<Context> context = env()->context();
Expand Down Expand Up @@ -1418,7 +1425,7 @@ void Http2Session::HandleGoawayFrame(const nghttp2_frame* frame) {

// Called by OnFrameReceived when a complete ALTSVC frame has been received.
void Http2Session::HandleAltSvcFrame(const nghttp2_frame* frame) {
if (!(js_fields_[kBitfield] & (1 << kSessionHasAltsvcListeners))) return;
if (!(js_fields_.bitfield & (1 << kSessionHasAltsvcListeners))) return;
Isolate* isolate = env()->isolate();
HandleScope scope(isolate);
Local<Context> context = env()->context();
Expand Down Expand Up @@ -1497,7 +1504,7 @@ void Http2Session::HandlePingFrame(const nghttp2_frame* frame) {
return;
}

if (!(js_fields_[kBitfield] & (1 << kSessionHasPingListeners))) return;
if (!(js_fields_.bitfield & (1 << kSessionHasPingListeners))) return;
// Notify the session that a ping occurred
arg = Buffer::Copy(env(),
reinterpret_cast<const char*>(frame->ping.opaque_data),
Expand All @@ -1509,8 +1516,8 @@ void Http2Session::HandlePingFrame(const nghttp2_frame* frame) {
void Http2Session::HandleSettingsFrame(const nghttp2_frame* frame) {
bool ack = frame->hd.flags & NGHTTP2_FLAG_ACK;
if (!ack) {
js_fields_[kBitfield] &= ~(1 << kSessionRemoteSettingsIsUpToDate);
if (!(js_fields_[kBitfield] & (1 << kSessionHasRemoteSettingsListeners)))
js_fields_.bitfield &= ~(1 << kSessionRemoteSettingsIsUpToDate);
if (!(js_fields_.bitfield & (1 << kSessionHasRemoteSettingsListeners)))
return;
// This is not a SETTINGS acknowledgement, notify and return
MakeCallback(env()->http2session_on_settings_function(), 0, nullptr);
Expand Down Expand Up @@ -3055,6 +3062,8 @@ void Initialize(Local<Object> target,
NODE_DEFINE_CONSTANT(target, kBitfield);
NODE_DEFINE_CONSTANT(target, kSessionPriorityListenerCount);
NODE_DEFINE_CONSTANT(target, kSessionFrameErrorListenerCount);
NODE_DEFINE_CONSTANT(target, kSessionMaxInvalidFrames);
NODE_DEFINE_CONSTANT(target, kSessionMaxRejectedStreams);
NODE_DEFINE_CONSTANT(target, kSessionUint8FieldCount);

NODE_DEFINE_CONSTANT(target, kSessionHasRemoteSettingsListeners);
Expand Down
26 changes: 19 additions & 7 deletions src/node_http2.h
Original file line number Diff line number Diff line change
Expand Up @@ -673,15 +673,27 @@ class Http2Stream::Provider::Stream : public Http2Stream::Provider {
void* user_data);
};

typedef struct {
uint8_t bitfield;
uint8_t priority_listener_count;
uint8_t frame_error_listener_count;
uint32_t max_invalid_frames = 1000;
uint32_t max_rejected_streams = 100;
} SessionJSFields;

// Indices for js_fields_, which serves as a way to communicate data with JS
// land fast. In particular, we store information about the number/presence
// of certain event listeners in JS, and skip calls from C++ into JS if they
// are missing.
enum SessionUint8Fields {
kBitfield, // See below
kSessionPriorityListenerCount,
kSessionFrameErrorListenerCount,
kSessionUint8FieldCount
kBitfield = offsetof(SessionJSFields, bitfield), // See below
kSessionPriorityListenerCount =
offsetof(SessionJSFields, priority_listener_count),
kSessionFrameErrorListenerCount =
offsetof(SessionJSFields, frame_error_listener_count),
kSessionMaxInvalidFrames = offsetof(SessionJSFields, max_invalid_frames),
kSessionMaxRejectedStreams = offsetof(SessionJSFields, max_rejected_streams),
kSessionUint8FieldCount = sizeof(SessionJSFields)
};

enum SessionBitfieldFlags {
Expand Down Expand Up @@ -968,7 +980,7 @@ class Http2Session : public AsyncWrap, public StreamListener {
nghttp2_session* session_;

// JS-accessible numeric fields, as indexed by SessionUint8Fields.
uint8_t js_fields_[kSessionUint8FieldCount] = {};
SessionJSFields js_fields_ = {};

// The session type: client or server
nghttp2_session_type session_type_;
Expand Down Expand Up @@ -1014,9 +1026,9 @@ class Http2Session : public AsyncWrap, public StreamListener {
// limit will result in the session being destroyed, as an indication of a
// misbehaving peer. This counter is reset once new streams are being
// accepted again.
int32_t rejected_stream_count_ = 0;
uint32_t rejected_stream_count_ = 0;
// Also use the invalid frame count as a measure for rejecting input frames.
int32_t invalid_frame_count_ = 0;
uint32_t invalid_frame_count_ = 0;

void PushOutgoingBuffer(nghttp2_stream_write&& write);
void CopyDataIntoOutgoing(const uint8_t* src, size_t src_length);
Expand Down
47 changes: 45 additions & 2 deletions test/parallel/test-http2-createsecureserver-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ if (!common.hasCrypto)
const assert = require('assert');
const http2 = require('http2');

// Error if invalid options are passed to createSecureServer
// Error if invalid options are passed to createSecureServer.
const invalidOptions = [() => {}, 1, 'test', null, Symbol('test')];
invalidOptions.forEach((invalidOption) => {
assert.throws(
Expand All @@ -21,7 +21,7 @@ invalidOptions.forEach((invalidOption) => {
);
});

// Error if invalid options.settings are passed to createSecureServer
// Error if invalid options.settings are passed to createSecureServer.
invalidOptions.forEach((invalidSettingsOption) => {
assert.throws(
() => http2.createSecureServer({ settings: invalidSettingsOption }),
Expand All @@ -33,3 +33,46 @@ invalidOptions.forEach((invalidSettingsOption) => {
}
);
});

// Test that http2.createSecureServer validates input options.
Object.entries({
maxSessionInvalidFrames: [
{
val: -1,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
{
val: Number.NEGATIVE_INFINITY,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
],
maxSessionRejectedStreams: [
{
val: -1,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
{
val: Number.NEGATIVE_INFINITY,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
],
}).forEach(([opt, tests]) => {
tests.forEach(({ val, err }) => {
assert.throws(
() => http2.createSecureServer({ [opt]: val }),
err
);
});
});
47 changes: 45 additions & 2 deletions test/parallel/test-http2-createserver-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ if (!common.hasCrypto)
const assert = require('assert');
const http2 = require('http2');

// Error if invalid options are passed to createServer
// Error if invalid options are passed to createServer.
const invalidOptions = [1, true, 'test', null, Symbol('test')];
invalidOptions.forEach((invalidOption) => {
assert.throws(
Expand All @@ -21,7 +21,7 @@ invalidOptions.forEach((invalidOption) => {
);
});

// Error if invalid options.settings are passed to createServer
// Error if invalid options.settings are passed to createServer.
invalidOptions.forEach((invalidSettingsOption) => {
assert.throws(
() => http2.createServer({ settings: invalidSettingsOption }),
Expand All @@ -33,3 +33,46 @@ invalidOptions.forEach((invalidSettingsOption) => {
}
);
});

// Test that http2.createServer validates input options.
Object.entries({
maxSessionInvalidFrames: [
{
val: -1,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
{
val: Number.NEGATIVE_INFINITY,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
],
maxSessionRejectedStreams: [
{
val: -1,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
{
val: Number.NEGATIVE_INFINITY,
err: {
name: 'RangeError',
code: 'ERR_OUT_OF_RANGE',
},
},
]
}).forEach(([opt, tests]) => {
tests.forEach(({ val, err }) => {
assert.throws(
() => http2.createServer({ [opt]: val }),
err
);
});
});
Loading