Skip to content

Commit

Permalink
http: add requestTimeout
Browse files Browse the repository at this point in the history
This commits introduces a new http.Server option called requestTimeout
with a default value in milliseconds of 0.

If requestTimeout is set to a positive value, the server will start a new
timer set to expire in requestTimeout milliseconds when a new connection
is established. The timer is also set again if new requests after the
first are received on the socket (this handles pipelining and keep-alive
cases).
The timer is cancelled when:

1. the request body is completely received by the server.
2. the response is completed. This handles the case where the
application responds to the client without consuming the request body.
3. the connection is upgraded, like in the WebSocket case.

If the timer expires, then the server responds with status code 408 and
closes the connection.

CVE-2020-8251

PR-URL: nodejs-private/node-private#208
Reviewed-By: Franziska Hinkelmann <franziska.hinkelmann@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Mary Marchini <oss@mmarchini.me>
Co-Authored-By: Paolo Insogna <paolo@cowtech.it>
Co-Authored-By: Robert Nagy <ronagy@icloud.com>
  • Loading branch information
3 people authored and richardlau committed Sep 15, 2020
1 parent dd82837 commit 753f3b2
Show file tree
Hide file tree
Showing 14 changed files with 517 additions and 8 deletions.
5 changes: 5 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,11 @@ allowed size for a `Buffer`.
An invalid symlink type was passed to the [`fs.symlink()`][] or
[`fs.symlinkSync()`][] methods.

<a id="ERR_HTTP_REQUEST_TIMEOUT"></a>
### `ERR_HTTP_REQUEST_TIMEOUT`

The client has not sent the entire request within the allowed time.

<a id="ERR_HTTP_HEADERS_SENT"></a>
### `ERR_HTTP_HEADERS_SENT`

Expand Down
17 changes: 17 additions & 0 deletions doc/api/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,23 @@ added: v0.7.0

Limits maximum incoming headers count. If set to 0, no limit will be applied.

### `server.requestTimeout`
<!-- YAML
added: REPLACEME
-->

* {number} **Default:** `0`

Sets the timeout value in milliseconds for receiving the entire request from
the client.

If the timeout expires, the server responds with status 408 without
forwarding the request to the request listener and then closes the connection.

It must be set to a non-zero value (e.g. 120 seconds) to proctect against
potential Denial-of-Service attacks in case the server is deployed without a
reverse proxy in front.

### `server.setTimeout([msecs][, callback])`
<!-- YAML
added: v0.9.12
Expand Down
10 changes: 10 additions & 0 deletions doc/api/https.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ This method is identical to [`server.listen()`][] from [`net.Server`][].

See [`http.Server#maxHeadersCount`][].

### `server.requestTimeout`
<!-- YAML
added: REPLACEME
-->

* {number} **Default:** `0`

See [`http.Server#requestTimeout`][].

### `server.setTimeout([msecs][, callback])`
<!-- YAML
added: v0.11.2
Expand Down Expand Up @@ -449,6 +458,7 @@ headers: max-age=0; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; p
[`http.Server#headersTimeout`]: http.html#http_server_headerstimeout
[`http.Server#keepAliveTimeout`]: http.html#http_server_keepalivetimeout
[`http.Server#maxHeadersCount`]: http.html#http_server_maxheaderscount
[`http.Server#requestTimeout`]: http.html#http_server_requesttimeout
[`http.Server#setTimeout()`]: http.html#http_server_settimeout_msecs_callback
[`http.Server#timeout`]: http.html#http_server_timeout
[`http.Server`]: http.html#http_class_http_server
Expand Down
8 changes: 8 additions & 0 deletions lib/_http_common.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
});

const kIncomingMessage = Symbol('IncomingMessage');
const kRequestTimeout = Symbol('RequestTimeout');
const kOnHeaders = HTTPParser.kOnHeaders | 0;
const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0;
const kOnBody = HTTPParser.kOnBody | 0;
Expand Down Expand Up @@ -99,6 +100,12 @@ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method,
incoming.url = url;
incoming.upgrade = upgrade;

if (socket) {
debug('requestTimeout timer moved to req');
incoming[kRequestTimeout] = incoming.socket[kRequestTimeout];
incoming.socket[kRequestTimeout] = undefined;
}

let n = headers.length;

// If parser.maxHeaderPairs <= 0 assume that there's no limit.
Expand Down Expand Up @@ -264,6 +271,7 @@ module.exports = {
methods,
parsers,
kIncomingMessage,
kRequestTimeout,
HTTPParser,
isLenient,
prepareError,
Expand Down
85 changes: 83 additions & 2 deletions lib/_http_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const {
continueExpression,
chunkExpression,
kIncomingMessage,
kRequestTimeout,
HTTPParser,
isLenient,
_checkInvalidHeaderChar: checkInvalidHeaderChar,
Expand All @@ -57,6 +58,7 @@ const {
} = require('internal/async_hooks');
const { IncomingMessage } = require('_http_incoming');
const {
ERR_HTTP_REQUEST_TIMEOUT,
ERR_HTTP_HEADERS_SENT,
ERR_HTTP_INVALID_STATUS_CODE,
ERR_INVALID_ARG_TYPE,
Expand All @@ -72,6 +74,7 @@ const {
DTRACE_HTTP_SERVER_RESPONSE
} = require('internal/dtrace');
const { observerCounts, constants } = internalBinding('performance');
const { setTimeout, clearTimeout } = require('timers');
const { NODE_PERFORMANCE_ENTRY_TYPE_HTTP } = constants;

const kServerResponse = Symbol('ServerResponse');
Expand Down Expand Up @@ -143,6 +146,7 @@ const STATUS_CODES = {
511: 'Network Authentication Required' // RFC 6585 6
};

const kOnMessageBegin = HTTPParser.kOnMessageBegin | 0;
const kOnExecute = HTTPParser.kOnExecute | 0;
const kOnTimeout = HTTPParser.kOnTimeout | 0;

Expand Down Expand Up @@ -360,6 +364,7 @@ function Server(options, requestListener) {
this.keepAliveTimeout = 5000;
this.maxHeadersCount = null;
this.headersTimeout = 60 * 1000; // 60 seconds
this.requestTimeout = 0; // 120 seconds

This comment has been minimized.

Copy link
@ronag

ronag Sep 18, 2020

Author Member

Comment should be fixed

}
ObjectSetPrototypeOf(Server.prototype, net.Server.prototype);
ObjectSetPrototypeOf(Server, net.Server);
Expand Down Expand Up @@ -481,6 +486,16 @@ function connectionListenerInternal(server, socket) {
parser[kOnTimeout] =
onParserTimeout.bind(undefined, server, socket);

// When receiving new requests on the same socket (pipelining or keep alive)
// make sure the requestTimeout is active.
parser[kOnMessageBegin] =
setRequestTimeout.bind(undefined, server, socket);

// This protects from DOS attack where an attacker establish the connection
// without sending any data on applications where server.timeout is left to
// the default value of zero.
setRequestTimeout(server, socket);

socket._paused = false;
}

Expand Down Expand Up @@ -567,6 +582,11 @@ function socketOnData(server, socket, parser, state, d) {
onParserExecuteCommon(server, socket, parser, state, ret, d);
}

function onRequestTimeout(socket) {
socket[kRequestTimeout] = undefined;
socketOnError.call(socket, new ERR_HTTP_REQUEST_TIMEOUT());
}

function onParserExecute(server, socket, parser, state, ret) {
// When underlying `net.Socket` instance is consumed - no
// `data` events are emitted, and thus `socket.setTimeout` fires the
Expand All @@ -589,6 +609,10 @@ const badRequestResponse = Buffer.from(
`HTTP/1.1 400 ${STATUS_CODES[400]}${CRLF}` +
`Connection: close${CRLF}${CRLF}`, 'ascii'
);
const requestTimeoutResponse = Buffer.from(
`HTTP/1.1 408 ${STATUS_CODES[408]}${CRLF}` +
`Connection: close${CRLF}${CRLF}`, 'ascii'
);
const requestHeaderFieldsTooLargeResponse = Buffer.from(
`HTTP/1.1 431 ${STATUS_CODES[431]}${CRLF}` +
`Connection: close${CRLF}${CRLF}`, 'ascii'
Expand All @@ -600,8 +624,20 @@ function socketOnError(e) {

if (!this.server.emit('clientError', e, this)) {
if (this.writable && this.bytesWritten === 0) {
const response = e.code === 'HPE_HEADER_OVERFLOW' ?
requestHeaderFieldsTooLargeResponse : badRequestResponse;
let response;

switch (e.code) {
case 'HPE_HEADER_OVERFLOW':
response = requestHeaderFieldsTooLargeResponse;
break;
case 'ERR_HTTP_REQUEST_TIMEOUT':
response = requestTimeoutResponse;
break;
default:
response = badRequestResponse;
break;
}

this.write(response);
}
this.destroy(e);
Expand Down Expand Up @@ -641,11 +677,20 @@ function onParserExecuteCommon(server, socket, parser, state, ret, d) {
const bodyHead = d.slice(ret, d.length);

socket.readableFlowing = null;

// Clear the requestTimeout after upgrading the connection.
clearRequestTimeout(req);

server.emit(eventName, req, socket, bodyHead);
} else {
// Got CONNECT method, but have no handler.
socket.destroy();
}
} else {
// When receiving new requests on the same socket (pipelining or keep alive)
// make sure the requestTimeout is active.
parser[kOnMessageBegin] =
setRequestTimeout.bind(undefined, server, socket);
}

if (socket._paused && socket.parser) {
Expand All @@ -671,6 +716,32 @@ function clearIncoming(req) {
}
}

function setRequestTimeout(server, socket) {
// Set the request timeout handler.
if (
!socket[kRequestTimeout] &&
server.requestTimeout && server.requestTimeout > 0
) {
debug('requestTimeout timer set');
socket[kRequestTimeout] =
setTimeout(onRequestTimeout, server.requestTimeout, socket).unref();
}
}

function clearRequestTimeout(req) {
if (!req) {
req = this;
}

if (!req[kRequestTimeout]) {
return;
}

debug('requestTimeout timer cleared');
clearTimeout(req[kRequestTimeout]);
req[kRequestTimeout] = undefined;
}

function resOnFinish(req, res, socket, state, server) {
// Usually the first incoming element should be our request. it may
// be that in the case abortIncoming() was called that the incoming
Expand All @@ -685,6 +756,14 @@ function resOnFinish(req, res, socket, state, server) {
if (!req._consuming && !req._readableState.resumeScheduled)
req._dump();

// Make sure the requestTimeout is cleared before finishing.
// This might occur if the application has sent a response
// without consuming the request body, which would have alredy
// cleared the timer.
// clearRequestTimeout can be executed even if the timer is not active,
// so this is safe.
clearRequestTimeout(req);

res.detachSocket(socket);
clearIncoming(req);
process.nextTick(emitCloseNT, res);
Expand Down Expand Up @@ -779,6 +858,8 @@ function parserOnIncoming(server, socket, state, req, keepAlive) {
res.end();
}
} else {
req.on('end', clearRequestTimeout);

server.emit('request', req, res);
}
return 0; // No special treatment.
Expand Down
1 change: 1 addition & 0 deletions lib/https.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function Server(opts, requestListener) {
this.keepAliveTimeout = 5000;
this.maxHeadersCount = null;
this.headersTimeout = 60 * 1000; // 60 seconds
this.requestTimeout = 120 * 1000; // 120 seconds

This comment has been minimized.

Copy link
@ronag

ronag Sep 18, 2020

Author Member

This is wrong

This comment has been minimized.

Copy link
@ronag

ronag Sep 18, 2020

Author Member

Default should be 0

}
ObjectSetPrototypeOf(Server.prototype, tls.Server.prototype);
ObjectSetPrototypeOf(Server, tls.Server);
Expand Down
1 change: 1 addition & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ E('ERR_HTTP_HEADERS_SENT',
E('ERR_HTTP_INVALID_HEADER_VALUE',
'Invalid value "%s" for header "%s"', TypeError);
E('ERR_HTTP_INVALID_STATUS_CODE', 'Invalid status code: %s', RangeError);
E('ERR_HTTP_REQUEST_TIMEOUT', 'Request timeout', Error);
E('ERR_HTTP_TRAILER_INVALID',
'Trailers are invalid with this transfer encoding', Error);
E('ERR_INCOMPATIBLE_OPTION_PAIR',
Expand Down
28 changes: 22 additions & 6 deletions src/node_http_parser.cc
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ using v8::Uint32;
using v8::Undefined;
using v8::Value;

const uint32_t kOnHeaders = 0;
const uint32_t kOnHeadersComplete = 1;
const uint32_t kOnBody = 2;
const uint32_t kOnMessageComplete = 3;
const uint32_t kOnExecute = 4;
const uint32_t kOnTimeout = 5;
const uint32_t kOnMessageBegin = 0;
const uint32_t kOnHeaders = 1;
const uint32_t kOnHeadersComplete = 2;
const uint32_t kOnBody = 3;
const uint32_t kOnMessageComplete = 4;
const uint32_t kOnExecute = 5;
const uint32_t kOnTimeout = 6;
// Any more fields than this will be flushed into JS
const size_t kMaxHeaderFieldsCount = 32;

Expand Down Expand Up @@ -204,6 +205,19 @@ class Parser : public AsyncWrap, public StreamListener {
url_.Reset();
status_message_.Reset();
header_parsing_start_time_ = uv_hrtime();

Local<Value> cb = object()->Get(env()->context(), kOnMessageBegin)
.ToLocalChecked();
if (cb->IsFunction()) {
InternalCallbackScope callback_scope(
this, InternalCallbackScope::kSkipTaskQueues);

MaybeLocal<Value> r = cb.As<Function>()->Call(
env()->context(), object(), 0, nullptr);

if (r.IsEmpty()) callback_scope.MarkAsFailed();
}

return 0;
}

Expand Down Expand Up @@ -939,6 +953,8 @@ void InitializeHttpParser(Local<Object> target,
Integer::New(env->isolate(), HTTP_REQUEST));
t->Set(FIXED_ONE_BYTE_STRING(env->isolate(), "RESPONSE"),
Integer::New(env->isolate(), HTTP_RESPONSE));
t->Set(FIXED_ONE_BYTE_STRING(env->isolate(), "kOnMessageBegin"),
Integer::NewFromUnsigned(env->isolate(), kOnMessageBegin));
t->Set(FIXED_ONE_BYTE_STRING(env->isolate(), "kOnHeaders"),
Integer::NewFromUnsigned(env->isolate(), kOnHeaders));
t->Set(FIXED_ONE_BYTE_STRING(env->isolate(), "kOnHeadersComplete"),
Expand Down
62 changes: 62 additions & 0 deletions test/parallel/test-http-server-request-timeout-delayed-body.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const { createServer } = require('http');
const { connect } = require('net');

// This test validates that the server returns 408
// after server.requestTimeout if the client
// pauses before start sending the body.

const server = createServer(common.mustCall((req, res) => {
let body = '';
req.setEncoding('utf-8');

req.on('data', (chunk) => {
body += chunk;
});

req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write(body);
res.end();
});
}));

// 0 seconds is the default
assert.strictEqual(server.requestTimeout, 0);
const requestTimeout = common.platformTimeout(1000);
server.requestTimeout = requestTimeout;
assert.strictEqual(server.requestTimeout, requestTimeout);

server.listen(0, common.mustCall(() => {
const client = connect(server.address().port);
let response = '';

client.on('data', common.mustCall((chunk) => {
response += chunk.toString('utf-8');
}));

client.resume();
client.write('POST / HTTP/1.1\r\n');
client.write('Content-Length: 20\r\n');
client.write('Connection: close\r\n');
client.write('\r\n');

setTimeout(() => {
client.write('12345678901234567890\r\n\r\n');
}, common.platformTimeout(2000)).unref();

const errOrEnd = common.mustCall(function(err) {
console.log(err);
assert.strictEqual(
response,
'HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n'
);
server.close();
});

client.on('end', errOrEnd);
client.on('error', errOrEnd);
}));
Loading

0 comments on commit 753f3b2

Please sign in to comment.