Skip to content

Commit

Permalink
http,https: protect against slow headers attack
Browse files Browse the repository at this point in the history
CVE-2018-12122

An attacker can send a char/s within headers and exahust the resources
(file descriptors) of a system even with a tight max header length
protection. This PR destroys a socket if it has not received the headers
in 40s.

PR-URL: nodejs-private/node-private#150
Ref: nodejs-private/node-private#144
Reviewed-By: Sam Roberts <vieuxtech@gmail.com>
Reviewed-By: Ben Noordhuis <info@bnoordhuis.nl>
Reviewed-By: James M Snell <jasnell@gmail.com>
  • Loading branch information
mcollina authored and rvagg committed Nov 27, 2018
1 parent a8532d4 commit eb43bc0
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 11 deletions.
20 changes: 20 additions & 0 deletions doc/api/http.md
Expand Up @@ -941,6 +941,26 @@ added: v0.7.0


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


### server.headersTimeout
<!-- YAML
added: REPLACEME
-->

* {number} **Default:** `40000`

Limit the amount of time the parser will wait to receive the complete HTTP
headers.

In case of inactivity, the rules defined in [server.timeout][] apply. However,
that inactivity based timeout would still allow the connection to be kept open
if the headers are being sent very slowly (by default, up to a byte per 2
minutes). In order to prevent this, whenever header data arrives an additional
check is made that more than `server.headersTimeout` milliseconds has not
passed since the connection was established. If the check fails, a `'timeout'`
event is emitted on the server object, and (by default) the socket is destroyed.
See [server.timeout][] for more information on how timeout behaviour can be
customised.

### server.setTimeout([msecs][, callback]) ### server.setTimeout([msecs][, callback])
<!-- YAML <!-- YAML
added: v0.9.12 added: v0.9.12
Expand Down
7 changes: 7 additions & 0 deletions doc/api/https.md
Expand Up @@ -43,6 +43,12 @@ This method is identical to [`server.listen()`][] from [`net.Server`][].


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


### server.headersTimeout

- {number} **Default:** `40000`

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

### server.setTimeout([msecs][, callback]) ### server.setTimeout([msecs][, callback])
<!-- YAML <!-- YAML
added: v0.11.2 added: v0.11.2
Expand Down Expand Up @@ -360,6 +366,7 @@ headers: max-age=0; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; p
[`http.Agent`]: http.html#http_class_http_agent [`http.Agent`]: http.html#http_class_http_agent
[`http.Server#keepAliveTimeout`]: http.html#http_server_keepalivetimeout [`http.Server#keepAliveTimeout`]: http.html#http_server_keepalivetimeout
[`http.Server#maxHeadersCount`]: http.html#http_server_maxheaderscount [`http.Server#maxHeadersCount`]: http.html#http_server_maxheaderscount
[`http.Server#headersTimeout`]: http.html#http_server_headerstimeout
[`http.Server#setTimeout()`]: http.html#http_server_settimeout_msecs_callback [`http.Server#setTimeout()`]: http.html#http_server_settimeout_msecs_callback
[`http.Server#timeout`]: http.html#http_server_timeout [`http.Server#timeout`]: http.html#http_server_timeout
[`http.Server`]: http.html#http_class_http_server [`http.Server`]: http.html#http_class_http_server
Expand Down
22 changes: 21 additions & 1 deletion lib/_http_server.js
Expand Up @@ -37,7 +37,7 @@ const {
_checkInvalidHeaderChar: checkInvalidHeaderChar _checkInvalidHeaderChar: checkInvalidHeaderChar
} = require('_http_common'); } = require('_http_common');
const { OutgoingMessage } = require('_http_outgoing'); const { OutgoingMessage } = require('_http_outgoing');
const { outHeadersKey, ondrain } = require('internal/http'); const { outHeadersKey, ondrain, nowDate } = require('internal/http');
const { const {
defaultTriggerAsyncIdScope, defaultTriggerAsyncIdScope,
getOrSetAsyncId getOrSetAsyncId
Expand Down Expand Up @@ -303,6 +303,7 @@ function Server(options, requestListener) {
this.keepAliveTimeout = 5000; this.keepAliveTimeout = 5000;
this._pendingResponseData = 0; this._pendingResponseData = 0;
this.maxHeadersCount = null; this.maxHeadersCount = null;
this.headersTimeout = 40 * 1000; // 40 seconds
} }
util.inherits(Server, net.Server); util.inherits(Server, net.Server);


Expand Down Expand Up @@ -341,6 +342,9 @@ function connectionListenerInternal(server, socket) {
var parser = parsers.alloc(); var parser = parsers.alloc();
parser.reinitialize(HTTPParser.REQUEST); parser.reinitialize(HTTPParser.REQUEST);
parser.socket = socket; parser.socket = socket;

// We are starting to wait for our headers.
parser.parsingHeadersStart = nowDate();
socket.parser = parser; socket.parser = parser;


// Propagate headers limit from server instance to parser // Propagate headers limit from server instance to parser
Expand Down Expand Up @@ -478,7 +482,20 @@ function socketOnData(server, socket, parser, state, d) {


function onParserExecute(server, socket, parser, state, ret) { function onParserExecute(server, socket, parser, state, ret) {
socket._unrefTimer(); socket._unrefTimer();
const start = parser.parsingHeadersStart;
debug('SERVER socketOnParserExecute %d', ret); debug('SERVER socketOnParserExecute %d', ret);

// If we have not parsed the headers, destroy the socket
// after server.headersTimeout to protect from DoS attacks.
// start === 0 means that we have parsed headers.
if (start !== 0 && nowDate() - start > server.headersTimeout) {
const serverTimeout = server.emit('timeout', socket);

if (!serverTimeout)
socket.destroy();
return;
}

onParserExecuteCommon(server, socket, parser, state, ret, undefined); onParserExecuteCommon(server, socket, parser, state, ret, undefined);
} }


Expand Down Expand Up @@ -589,6 +606,9 @@ function resOnFinish(req, res, socket, state, server) {
function parserOnIncoming(server, socket, state, req, keepAlive) { function parserOnIncoming(server, socket, state, req, keepAlive) {
resetSocketTimeout(server, socket, state); resetSocketTimeout(server, socket, state);


// Set to zero to communicate that we have finished parsing.
socket.parser.parsingHeadersStart = 0;

if (req.upgrade) { if (req.upgrade) {
req.upgrade = req.method === 'CONNECT' || req.upgrade = req.method === 'CONNECT' ||
server.listenerCount('upgrade') > 0; server.listenerCount('upgrade') > 0;
Expand Down
1 change: 1 addition & 0 deletions lib/https.js
Expand Up @@ -75,6 +75,7 @@ function Server(opts, requestListener) {
this.timeout = 2 * 60 * 1000; this.timeout = 2 * 60 * 1000;
this.keepAliveTimeout = 5000; this.keepAliveTimeout = 5000;
this.maxHeadersCount = null; this.maxHeadersCount = null;
this.headersTimeout = 40 * 1000; // 40 seconds
} }
inherits(Server, tls.Server); inherits(Server, tls.Server);


Expand Down
27 changes: 19 additions & 8 deletions lib/internal/http.js
Expand Up @@ -2,19 +2,29 @@


const { setUnrefTimeout } = require('internal/timers'); const { setUnrefTimeout } = require('internal/timers');


var dateCache; var nowCache;
var utcCache;

function nowDate() {
if (!nowCache) cache();
return nowCache;
}

function utcDate() { function utcDate() {
if (!dateCache) { if (!utcCache) cache();
const d = new Date(); return utcCache;
dateCache = d.toUTCString(); }


setUnrefTimeout(resetCache, 1000 - d.getMilliseconds()); function cache() {
} const d = new Date();
return dateCache; nowCache = d.valueOf();
utcCache = d.toUTCString();
setUnrefTimeout(resetCache, 1000 - d.getMilliseconds());
} }


function resetCache() { function resetCache() {
dateCache = undefined; nowCache = undefined;
utcCache = undefined;
} }


function ondrain() { function ondrain() {
Expand All @@ -24,5 +34,6 @@ function ondrain() {
module.exports = { module.exports = {
outHeadersKey: Symbol('outHeadersKey'), outHeadersKey: Symbol('outHeadersKey'),
ondrain, ondrain,
nowDate,
utcDate utcDate
}; };
4 changes: 2 additions & 2 deletions test/async-hooks/test-graph.http.js
Expand Up @@ -52,10 +52,10 @@ process.on('exit', function() {
triggerAsyncId: 'tcp:2' }, triggerAsyncId: 'tcp:2' },
{ type: 'Timeout', { type: 'Timeout',
id: 'timeout:2', id: 'timeout:2',
triggerAsyncId: 'httpparser:4' }, triggerAsyncId: 'tcp:2' },
{ type: 'TIMERWRAP', { type: 'TIMERWRAP',
id: 'timer:2', id: 'timer:2',
triggerAsyncId: 'httpparser:4' }, triggerAsyncId: 'tcp:2' },
{ type: 'SHUTDOWNWRAP', { type: 'SHUTDOWNWRAP',
id: 'shutdown:1', id: 'shutdown:1',
triggerAsyncId: 'tcp:2' } ] triggerAsyncId: 'tcp:2' } ]
Expand Down
50 changes: 50 additions & 0 deletions test/parallel/test-http-slow-headers.js
@@ -0,0 +1,50 @@
'use strict';

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

// This test validates that the 'timeout' event fires
// after server.headersTimeout.

const headers =
'GET / HTTP/1.1\r\n' +
'Host: localhost\r\n' +
'Agent: node\r\n';

const server = createServer(common.mustNotCall());
let sendCharEvery = 1000;

// 40 seconds is the default
assert.strictEqual(server.headersTimeout, 40 * 1000);

// Pass a REAL env variable to shortening up the default
// value which is 40s otherwise this is useful for manual
// testing
if (!process.env.REAL) {
sendCharEvery = common.platformTimeout(10);
server.headersTimeout = 2 * sendCharEvery;
}

server.once('timeout', common.mustCall((socket) => {
socket.destroy();
}));

server.listen(0, common.mustCall(() => {
const client = connect(server.address().port);
client.write(headers);
client.write('X-CRASH: ');

const interval = setInterval(() => {
client.write('a');
}, sendCharEvery);

client.resume();

finished(client, common.mustCall((err) => {
clearInterval(interval);
server.close();
}));
}));
63 changes: 63 additions & 0 deletions test/parallel/test-https-slow-headers.js
@@ -0,0 +1,63 @@
'use strict';

const common = require('../common');
const { readKey } = require('../common/fixtures');

if (!common.hasCrypto)
common.skip('missing crypto');

const assert = require('assert');
const { createServer } = require('https');
const { connect } = require('tls');
const { finished } = require('stream');

// This test validates that the 'timeout' event fires
// after server.headersTimeout.

const headers =
'GET / HTTP/1.1\r\n' +
'Host: localhost\r\n' +
'Agent: node\r\n';

const server = createServer({
key: readKey('agent1-key.pem'),
cert: readKey('agent1-cert.pem'),
ca: readKey('ca1-cert.pem'),
}, common.mustNotCall());

let sendCharEvery = 1000;

// 40 seconds is the default
assert.strictEqual(server.headersTimeout, 40 * 1000);

// pass a REAL env variable to shortening up the default
// value which is 40s otherwise
// this is useful for manual testing
if (!process.env.REAL) {
sendCharEvery = common.platformTimeout(10);
server.headersTimeout = 2 * sendCharEvery;
}

server.once('timeout', common.mustCall((socket) => {
socket.destroy();
}));

server.listen(0, common.mustCall(() => {
const client = connect({
port: server.address().port,
rejectUnauthorized: false
});
client.write(headers);
client.write('X-CRASH: ');

const interval = setInterval(() => {
client.write('a');
}, sendCharEvery);

client.resume();

finished(client, common.mustCall((err) => {
clearInterval(interval);
server.close();
}));
}));

0 comments on commit eb43bc0

Please sign in to comment.