本文介绍关于nodejs中的http慢请求攻击问题。 首先我们写一个测试服务器
const http = require('http');
const server = http.createServer((req,res) => {}).listen(80);
// 3秒还没有解析完http头则关闭连接
server.headersTimeout = 3000
接着我们写个测试客户端
const net = require('net');
const socket = net.connect({port: 80});
socket.write('POST / HTTP/1.1\r\ncontent-length:1\r\n');
const timer = setInterval(() => {
socket.write('1\r\n')
}, 4000);
const start = Date.now();
socket.on('end', () => {
clearInterval(timer);
console.log(Date.now() - start)
})
客户端先发送一部分http报文,4秒后再继续发。我们发现,虽然我们服务器设置了3秒超时,但是无效。连接会在4秒后断开。我们看看源码(node14.6.0)为什么。 node_http_parser.cc的OnStreamRead
// 开始解析http的时间,0说明解析完了。如果正在解析且设置了timeout则判断是否超时(server.setTimeout())
if (header_parsing_start_time_ != 0 && headers_timeout_ != 0) {
uint64_t now = uv_hrtime();
// 算出已经解析的时间间隔
uint64_t parsing_time = (now - header_parsing_start_time_) / 1e6;
// 是否过期了
if (parsing_time > headers_timeout_) {
Local<Value> cb =
object()->Get(env()->context(), kOnTimeout).ToLocalChecked();
if (!cb->IsFunction())
return;
MakeCallback(cb.As<Function>(), 0, nullptr);
return;
}
}
以上是判断解析http头过期的逻辑,看起来没问题,nodejs处理了这个问题,那为什么在我们设定的时间内没有触发断开连接呢?问题在于这段逻辑(OnStreamRead函数)是有数据的时候执行的回调,所以这依赖于客户端发送数据才能触发,否则就不会进入这个逻辑,也就无法断开连接。我们顺便看一下触发这段逻辑的时候会怎样。
function onParserTimeout(server, socket) {
const serverTimeout = server.emit('timeout', socket);
if (!serverTimeout)
socket.destroy();
}
默认是断开连接,如果我们监听了server的timeout事件,则需要自己处理连接问题。至此我们明白了为什么服务器的设置无效了。 我们改一下客户端代码再测试一下。
const net = require('net');
const socket = net.connect({port: 80});
socket.write('POST / HTTP/1.1\r\ncontent-length:1\r\n');
const start = Date.now();
socket.on('end', () => {
console.log(Date.now() - start)
})
这次我们只发送一部分。超时后也不再发送数据了,这时候服务器会怎样呢?我们发现服务器会一直保持这个连接,经过上面的分析我们知道,当客户端不再发送数据的时候,是无法触发断连的逻辑的,这就会带来http慢请求攻击。那我们如何解决这个问题呢? nodejs中还有另外一个配置可以帮我们解决这个问题。那就是server.setTimeout();我们看看代码。
Server.prototype.setTimeout = function setTimeout(msecs, callback) {
this.timeout = msecs;
if (callback)
this.on('timeout', callback);
return this;
};
设置了一个值。这个值有什么用呢?我们接着看
function connectionListenerInternal(server, socket) {
if (server.timeout && typeof socket.setTimeout === 'function')
socket.setTimeout(server.timeout);
socket.on('timeout', socketOnTimeout);
}
以上代码是建立tcp连接成功的回调,nodejs会设置socket的空闲超时时间。回调是socketOnTimeout
function socketOnTimeout() {
const req = this.parser && this.parser.incoming;
const reqTimeout = req && !req.complete && req.emit('timeout', this);
const res = this._httpMessage;
const resTimeout = res && res.emit('timeout', this);
const serverTimeout = this.server.emit('timeout', this);
if (!reqTimeout && !resTimeout && !serverTimeout)
this.destroy();
}
超时回调的时候,默认会关闭连接。所以我们可以通过这个配置解决慢请求攻击。
这个问题不是nodejs一直存在的,在早期的版本(比如12.20.0)的时候是没有这个问题的。
uint64_t http_server_default_timeout = 120000;
const kDefaultHttpServerTimeout = getOptionValue('--http-server-default-timeout');
function Server(options, requestListener) {
this.timeout = kDefaultHttpServerTimeout;
}
我们看到nodejs早期版本里timeout的默认值是两分钟,这意味着nodejs默认处理了这个问题,而v14.6.0中则是0。具体可以参考(https://github.com/nodejs/node/commit/c30ef3cbd2e42ac1d600f6bd78a601a5496b0877 ),这时候我们可能想到keepalive机制,但是linux下keepalive默认是不开启的,而且因为nodejs还没解析完http头,没有回调我们,我们也拿不到socket对象,从而也无法开启keepalive机制。