Node.js安全教程:防止阻塞Event Loop的潜在攻击


发布者 newghost  发布时间 1420606197252
关键字 骇客攻防  JavaScript 
注* 发表于2012年

有一些人问过我:

我们的node.js服务器会偶尔挂一段时间(几秒钟),为什么会这样?

那么,为什么会这样呢?简单的回答是,我们的代码时不时地阻塞了node.js的事件循环(Event Loop)。你可能已经知道了node.js —— 像浏览器中的JavaScript一样 —— 是单线程的。是由一个事件循环驱动的。一次只会处理一件事件。并行处理在这里是不存在的。所以node.js很善于处理I/O密集型的工作。因为在处理一个请求时,大部分时间是花在I/O等待上面了。(从磁盘上读取数据,从网络收发数据),但是它并不善长处理CPU密集型的工作。

当没有太多的计算量且不要求马上返回结果时,这种协同可以很好的工作,比如:

function requestHandler(req, res) {
   db.getUser(req.params.uid, function(err, user) {
       res.end(user.username);
   });
}

JavaScript在很多方面的设计都有缺陷,但是它有一个地方的设计是对的:无论何时requestHandler被调用(假设一个HTTP请求传进来),它会立即进行一次异步调用。假设db.getUser是一个异步操作,跟看上去的那样,它只需要很少的一些计算量,然后立即进行下一个异步I/O操作。


一年以前,Ted Dziuba 发表过 NodeJS就是癌症[2011]  ,其中有一个重要的观点是

让我们先从看看一些场景。拿函数调用来说,当执行此函数时当前线程会等到该功能结束,然后再继续下一个操作。通常情况下,我们认为I/O是“阻塞”的,比如你在调用socket.read(),程序会等待该调用完成后继续,因为你需要它的返回值。

这里有一个有趣的现象:每个函数的调用,在CPU里也是阻塞的。比如此功能,它计算的第n个Fibonacci数(斐波那契数列),将被阻塞CPU当前的执行线程。

function fibonacci(n) {
  if (n < 2)
    return 1;
  else
    return fibonacci(n-2) + fibonacci(n-1);
}

他证明了用node.js写斐波那契数列的性能的确很差。这一点没问题,但是我们不需要在服务器端用node.js来进行这种程度的数学运算。但是node.js经常在服务器端进行的CPU密集和阻塞运算,是下面一种情形:

function requestHandler(req, res) {
   var body = req.rawBody; // Contains the POST body
   try {
      var json = JSON.parse(body);
      res.end(json.user.username);
   }
   catch(e) {
      res.end("FAIL");
   }
}


看起来没什么问题,对吧?它接收POST过来的请求,然后解析成JSON字符串,这种方法是有效的,直到有人将一个15mb的JSON文件抛过来。

我在自己的笔记本上测试过。执行JSON.parse()解析一个15Mb的JSON文件大概需要1.5秒。同样,当我使用格式化的解析 JSON.stringify(json, null, 2) 大约需要3秒钟。


你可以会想: 1.5秒,3秒仍然很快,但你意识到这个过程中事件循环是被完全阻塞的吗?这个时侯你的node服务器不能做任何事情。当然1.5Mb看起来确实比较大,但20个200Kb的文件就很合理了。你的服务器同样会挂起。

让我们假设之前1毫秒可以处理一个请求,即1/0.001 = 1000 请求/秒 (假设你不做任何I/O操作),这个看上去不错。现在Event Loop被阻塞之后呢?

  • 5ms/请求 = 最多 200 请求/秒
  • 50ms/请求 = 最多 20 请求/秒
  • 500ms/请求 = 最多 2 请求/秒

当然这个问题在许多其它技术中也会存在,基本原理是一样的,平均每个请求花的时间越长,服务器能处理的并发就越少。


注* 在主流框架中都会限制POST数据的大小,如Express:

Express -> body_parser -> raw-body 中:

有一处限制 limit 默认为 100Kb

function onData(chunk) {
  received += chunk.length
  decoder
    ? buffer += decoder.write(chunk)
    : buffer.push(chunk)

  if (limit !== null && received > limit) {
    var err = makeError('request entity too large', 'entity.too.large')
    //……
  }
}

但在受大量的POST数据攻击时,100Kb也是不够的。

OurJS博客采用限制发贴间隔,并可以起多个相互独立的服务器实例来避免单个服务的阻塞,只需配置redis(存放Session)即可,此功能尚在测试。只因流量尚未达到必须起两个以上服务的程度。