Node.js 中遇到含空格 URL 的神奇“Bug”——小范围深入 HTTP 协议
首先声明,我在“Bug”字眼上加了引号,自然是为了说明它并非一个真 Bug。
1. 问题抛出
昨天有个童鞋在看后台监控的时候,突然发现了一个错误:
[error] 000001#0: ... upstream prematurely closed connection while reading response header from upstream.
client: 10.10.10.10
server: foo.com
request: "GET /foo/bar?rmicmd,begin run clean docker images job HTTP/1.1"
upstream: "http://..."
大概意思就是说:一台服务器通过 HTTP 协议去请求另一台服务器的时候,单方面被对方服务器断开了连接——并且并没有任何返回。
2. 开始重现
2.1. 客户端 CURL 指令
其实这次请求的一些猫腻很容易就能发现——在 URL 中有空格。所以我们能简化出一条最简单的 CURL 指令:
$ curl "http://foo/bar baz" -v
注意: 不带任何转义。
2.2. 最小 Node.js 源码
好的,那么接下去开始写相应的最简单的 Node.js HTTP 服务端源码。
'use strict';
const http = require('http');
const server = http.createServer(function(req, resp) {
console.log('?');
resp.end('hello world');
server.listen(5555);
大功告成,启动这段 Node.js 代码,开始试试看上面的指令吧。
如果你也正在跟着尝试这件事情的话,你就会发现 Node.js 的命令行没有输出任何信息,尤其是嘲讽的
'�
�',而在 CURL 的结果中,你将会看见:
$ curl 'http://127.0.0.1:5555/d d' -v
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5555 (#0)
> GET /d d HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/7.54.0
> Accept: */*
* Empty reply from server
* Connection #0 to host 127.0.0.1 left intact
curl: (52) Empty reply from server
瞧, Empty reply from server 。
2.3. Nginx
发现了问题之后,就有另一个问题值得思考了:就 Node.js 会出现这种情况呢,还是其它一些 HTTP 服务器也会有这种情况呢。
于是拿小白鼠 Nginx 做了个实验。我写了这么一个配置:
server {
listen 5555;
location / {
return 200 $uri;
}
接着也执行一遍 CURL,得到了如下的结果:
$ curl 'http://127.0.0.1:5555/d d' -v
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5555 (#0)
> GET /d d HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/7.54.0
> Accept: */*
< HTTP/1.1 200 OK
< Server: openresty/1.11.2.1
< Date: Tue, 12 Dec 2017 09:07:56 GMT
< Content-Type: application/octet-stream
< Content-Length: 4
< Connection: keep-alive
* Connection #0 to host 127.0.0.1 left intact
/d d
于是乎,理所当然,我 暂时 将这个事件定性为 Node.js 的一个 Bug。
3. Node.js 源码排查
认定了它是个 Bug 之后,我就开始了一贯的看源码环节——由于这个 Bug 的复现条件比较明显,我暂时将其定性为“Node.js HTTP 服务端模块在接到请求后解析 HTTP 数据包的时候解析 URI 时出了问题”。
3.1. http.js -> _http_server.js -> _http_common.js
源码以 Node.js 8.9.2 为准。
这里先预留一下我们能马上想到的 http:// node_http_parser.cc ,而先讲这几个文件,是有原因的——这涉及到最后的一个应对方式。
首先看看 lib/http.js 的相应源码:
...
const server = require('_http_server');
const { Server } = server;
function createServer(requestListener) {
return new Server(requestListener);
那么,马上进入 lib/_http_server.js 看吧。
首先是创建一个 HttpParser 并绑上监听获取到 HTTP 数据包后解析结果的回调函数的代码:
const {
parsers,
} = require('_http_common');
function connectionListener(socket) {
var parser = parsers.alloc();
parser.reinitialize(HTTPParser.REQUEST);
parser.socket = socket;
socket.parser = parser;
parser.incoming = null;
state.onData = socketOnData.bind(undefined, this, socket, parser, state);
socket.on('data', state.onData);
function socketOnData(server, socket, parser, state, d) {
assert(!socket._paused);
debug('SERVER socketOnData %d', d.length);
var ret = parser.execute(d);
onParserExecuteCommon(server, socket, parser, state, ret, d);
从源码中文我们能看到,当一个 HTTP 请求过来的时候,监听函数
connectionListener()
会拿着 Socket 对象加上一个
data
事件监听——一旦有请求连接过来,就去执行
socketOnData()
函数。
而在
socketOnData()
函数中,做的主要事情就是
parser.execute(d)
来解析 HTTP 数据包,在解析完成后执行一下回调函数
onParserExecuteCommon()
。
至于这个
parser
,我们能看到它是从
lib/_http_common.js
中来的。
var parsers = new FreeList('parsers', 1000, function() {
var parser = new HTTPParser(HTTPParser.REQUEST);
parser[kOnHeaders] = parserOnHeaders;
parser[kOnHeadersComplete] = parserOnHeadersComplete;
parser[kOnBody] = parserOnBody;
parser[kOnMessageComplete] = parserOnMessageComplete;
parser[kOnExecute] = null;
return parser;
能看出来
parsers
是
HTTPParser
的一条 Free List(效果类似于最简易的动态内存池),每个 Parser 在初始化的时候绑定上了各种回调函数。具体的一些回调函数就不细讲了,有兴趣的童鞋可自行翻阅。
这么一来,链路就比较明晰了:
请求进来的时候,Server 对象会为该次请求的 Socket 分配一个
HttpParser
对象,并调用其
execute()
函数进行解析,在解析完成后调用
onParserExecuteCommon()
函数。
3.2. http:// node_http_parser.cc
我们在
lib/_http_common.js
中能发现,
HTTPParser
的实现存在于
src/
http://
node_http_parser.cc
中:
const binding = process.binding('http_parser');
const { methods, HTTPParser } = binding;
至于为什么
const binding = process.binding('http_parser')
就是对应到
src/
http://
node_http_parser.cc
文件,以及这一小节中下面的一些 C++ 源码相关分析,不明白且有兴趣的童鞋可自行去阅读更深一层的源码,或者网上搜索答案,或者我提前无耻硬广一下我快要上市的书《Node.js:来一打 C++ 扩展》——里面也有说明,以及我的有一场知乎 Live《深入理解 Node.js 包与模块机制》。
总而言之,我们接下去要看的就是 src/node_http_parser.cc 了。
env->SetProtoMethod(t, "close", Parser::Close);
env->SetProtoMethod(t, "execute", Parser::Execute);
env->SetProtoMethod(t, "finish", Parser::Finish);
env->SetProtoMethod(t, "reinitialize", Parser
::Reinitialize);
env->SetProtoMethod(t, "pause", Parser::Pause<true>);
env->SetProtoMethod(t, "resume", Parser::Pause<false>);
env->SetProtoMethod(t, "consume", Parser::Consume);
env->SetProtoMethod(t, "unconsume", Parser::Unconsume);
env->SetProtoMethod(t, "getCurrentBuffer", Parser::GetCurrentBuffer);
如代码片段所示,前文中
parser.execute()
所对应的函数就是
Parser::Execute()
了。
class Parser : public AsyncWrap {
static void Execute(const FunctionCallbackInfo<Value>& args) {
Parser* parser;
Local<Object> buffer_obj = args[0].As<Object>();
char* buffer_data = Buffer::Data(buffer_obj);
size_t buffer_len = Buffer::Length(buffer_obj);
Local<Value> ret = parser->Execute(buffer_data, buffer_len);
if (!ret.IsEmpty())
args.GetReturnValue().Set(ret);
Local<Value> Execute(char* data, size_t len) {
EscapableHandleScope scope(env()->isolate());
current_buffer_len_ = len;
current_buffer_data_ = data;
got_exception_ = false;
size_t nparsed =
http_parser_execute(&parser_, &settings, data, len);
Save();
// Unassign the 'buffer_' variable
current_buffer_.Clear();
current_buffer_len_ = 0;
current_buffer_data_ = nullptr;
// If there was an exception in one of the callbacks
if (got_exception_)
return scope.Escape(Local<Value>());
Local<Integer> nparsed_obj = Integer::New(env()->isolate(), nparsed);
// If there was a parse error in one of the callbacks
// TODO(bnoordhuis) What if there is an error on EOF?
if (!parser_.upgrade && nparsed != len) {
enum http_errno err = HTTP_PARSER_ERRNO(&parser_);
Local<Value> e = Exception::Error(env()->parse_error_string());
Local<Object> obj = e->ToObject(env()->isolate());
obj->Set(env()->bytes_parsed_string(), nparsed_obj);
obj->Set(env()->code_string(),
OneByteString(env()->isolate(), http_errno_name(err)));
return scope.Escape(e);
return scope.Escape(nparsed_obj);
首先进入
Parser
的静态
Execute()
函数,我们看到它把传进来的
Buffer
转化为 C++ 下的
char*
指针,并记录其数据长度,同时去执行当前调用的
parser
对象所对应的
Execute()
函数。
在这个
Execute()
函数中,有个最重要的代码,就是:
size_t nparsed =
http_parser_execute(&parser_, &settings, data, len);
这段代码是调用真正解析 HTTP 数据包的函数,它是 Node.js 这个项目的一个自研依赖,叫 http-parser 。它独立的项目地址在 https:// github.com/nodejs/http- parser ,我们本文中用的是 Node.js v8.9.2 中所依赖的源码,应该会有偏差。
3.3. http-parser
3.3.1. HTTP Request 数据包体
如果你已经对 HTTP 包体了解了,可以略过这一节。
HTTP 的 Request 数据包其实是文本格式的,在 Raw 的状态下,大概是以这样的形式存在:
方法 URI HTTP/版本
头1: 我是头1
头2: 我是头2
简单起见,这里就写出最基础的一些内容,至于 Body 什么的大家自己找资料看吧。
上面的是什么意思呢?我们看看 CURL 的结果就知道了,实际上对应
curl ... -v
的中间输出:
GET /test HTTP/1.1
Host: 127.0.0.1:5555
User-Agent: curl/7.54.0
Accept: */*
所以实际上大家平时在文章中、浏览器调试工具中看到的什么请求头啊什么的,都是以文本形式存在的,以换行符分割。
而——重点来了,导致我们本文所述“Bug”出现的请求,它的请求包如下:
GET /foo bar HTTP/1.1
Host: 127.0.0.1:5555
User-Agent: curl/7.54.0
Accept: */*
重点在第一行:
GET /foo bar HTTP/1.1
3.3.2. 源码解析
话不多少,我们之间前往 http-parser 的
http_parser.c
看
http_parser_execute ()
函数中的状态机变化。
从源码中文我们能看到,http-parser 的流程是从头到尾以 O(n) 的时间复杂度对字符串逐字扫描,并且不后退也不往前跳。
那么扫描到每个字符的时候,都有属于当前的一个状态,如“正在扫描处理 uri”、“正在扫描处理 HTTP 协议并且处理到了 H”、“正在扫描处理 HTTP 协议并且处理到了 HT”、“正在扫描处理 HTTP 协议并且处理到了 HTT”、“正在扫描处理 HTTP 协议并且处理到了 HTTP”、……
憋笑,这是真的,我们看看代码就知道了:
case s_req_server:
case s_req_server_with_at:
case s_req_path:
case s_req_query_string_start:
case
s_req_query_string:
case s_req_fragment_start:
case s_req_fragment:
switch (ch) {
case ' ':
UPDATE_STATE(s_req_http_start);
CALLBACK_DATA(url);
break;
case CR:
case LF:
parser->http_major = 0;
parser->http_minor = 9;
UPDATE_STATE((ch == CR) ?
s_req_line_almost_done :
s_header_field_start);
CALLBACK_DATA(url);
break;
default:
UPDATE_STATE(parse_url_char(CURRENT_STATE(), ch));
if (UNLIKELY(CURRENT_STATE() == s_dead)) {
SET_ERRNO(HPE_INVALID_URL);
goto error;
break;
}
在扫描的时候,如果当前状态是 URI 相关的(如
s_req_path
、
s_req_query_string
等),则执行一个子
switch
,里面的处理如下:
-
若当前字符是空格,则将状态改变为
s_req_http_start
并认为 URI 已经解析好了,通过宏CALLBACK_DATA()
触发 URI 解析好的事件; -
若当前字符是换行符,则说明还在解析 URI 的时候就被换行了,后面就不可能跟着 HTTP 协议版本的申明了,所以设置默认的 HTTP 版本为
0.9
,并修改当前状态,最后认为 URI 已经解析好了,通过宏CALLBACK_DATA()
触发 URI 解析好的事件; -
其余情况(所有其它字符)下,通过调用
parse_url_char()
函数来解析一些东西并更新当前状态。(因为哪怕是在解析 URI 状态中,也还有各种不同的细分,如s_req_path
、s_req_query_string
)
这里的重点还是当状态为解析 URI 的时候遇到了空格的处理,上面也解释过了,一旦遇到这种情况,则会认为 URI 已经解析好了,并且将状态修改为
s_req_http_start
。也就是说,有“Bug”的那个数据包
GET /foo bar HTTP/1.1
在解析到
foo
后面的空格的时候它就将状态改为
s_req_http_start
并且认为 URI 已经解析结束了。
好的,接下来我们看看
s_req_http_start
怎么处理:
case s_req_http_start:
switch (ch) {
case 'H':
UPDATE_STATE(s_req_http_H);
break;
case ' ':
break;
default:
SET_ERRNO(HPE_INVALID_CONSTANT);
goto error;
break;
case s_req_http_H:
STRICT_CHECK(ch != 'T');
UPDATE_STATE(s_req_http_HT);
break;
case s_req_http_HT:
case s_req_http_HTT:
case s_req_http_HTTP:
case s_req_first_http_major:
...
如代码所见,若当前状态为
s_req_http_start
,则先判断当前字符是不是合标。因为就 HTTP 请求包体的格式来看,如果 URI 解析结束的话,理应出现类似
HTTP/1.1
的这么一个版本申明。所以这个时候 http-parser 会直接判断当前字符是否为
H
。
-
若是
H
,则将状态改为s_req_http_H
并继续扫描循环的下一位,同理在s_req_http_H
下若合法状态就会变成s_req_http_HT
,以此类推; +若是空格,则认为是多余的空格,那么当前状态不做任何改变,并继续下一个扫描; -
但如果当前字符既不是空格也不是
H
,那么好了,http-parser 直接认为你的请求包不合法,将你本次的解析设置错误HPE_INVALID_CONSTANT
并goto
到error
代码块。
至此,我们基本上已经明白了原因了:
http-parser 认为在 HTTP 请求包体中,第一行的 URI 解析阶段一旦出现了空格,就会认为 URI 解析完成,继而解析 HTTP 协议版本。但若此时紧跟着的不是 HTTP 协议版本的标准格式,http-parser 就会认为你这是一个
HPE_INVALID_CONSTANT
的数据包。
不过,我们还是继续看看它的
error
代码块吧:
error:
if (HTTP_PARSER_ERRNO(parser) == HPE_OK) {
SET_ERRNO(HPE_UNKNOWN);
RETURN(p - data);
这段代码中首先判断一下当跳到这段代码的时候有没有设置错误,若没有设置错误则将错误设置为未知错误(
HPE_UNKNOWN
),然后返回已解析的数据包长度。
p
是当前解析字符指针,data
是这个数据包的起始指针,所以p - data
就是已解析的数据长度。如果成功解析完,这个数据包理论上是等于这个数据包的完整长度,若不等则理论上说明肯定是中途出错提前返回。
3.4. 回到 http:// node_http_parser.cc
看完了 http-parser 的原理后,很多地方茅塞顿开。现在我们回到它的调用地 node_http_parser.cc 继续阅读吧。
Local<Value> Execute(char* data, size_t len) {
size_t nparsed =
http_parser_execute(&parser_, &settings, data, len);
Local<Integer> nparsed_obj = Integer::New(env()->isolate(), nparsed);
if (!parser_.upgrade && nparsed != len) {
enum http_errno err = HTTP_PARSER_ERRNO(&parser_);
Local<Value> e = Exception::Error(env()->parse_error_string());
Local<Object> obj = e->ToObject(env()->isolate());
obj->Set(env()->bytes_parsed_string(), nparsed_obj);
obj->Set(env()->code_string(),
OneByteString(env()->isolate(), http_errno_name(err)));
return scope.Escape(e);
return scope.Escape(nparsed_obj);
从调用处我们能看见,在执行完
http_parser_execute()
后有一个判断,若当前请求不是
upgrade
请求(即请求头中有说明
Upgrade
,通常用于 WebSocket),并且解析长度不等于原数据包长度(前文说了这种情况属于出错了)的话,那么进入中间的错误代码块。
在错误代码块中,先
HTTP_PARSER_ERRNO(&parser_)
拿到错误码,然后通过
Exception::Error()
生成错误对象,将错误信息塞进错误对象中,最后返回错误对象。
如果没错,则返回解析长度(
nparsed_obj
即
nparsed
)。
在这个文件中,眼尖的童鞋可能发现了,执行
Execute()
有好多处,这是因为实际上一个 HTTP 请求可能是流式的,所以有时候可能会只拿到部分数据包。所以最后有一个结束符需要被确认。
这也是为什么 http-parser 在解析的时候只能逐字解析而不能跳跃或者后退了。
3.5. 回到 _http_server.js
我们把
Parser::Execute()
也就是 JavaScript 代码中的
parser.execute()
给搞清楚后,我们就能回到
_http_server.js
看代码了。
前文说了,
socketOnData
在解析完数据包后会执行
onParserExecuteCommon
函数,现在就来看看这个
onParserExecuteCommon()
函数。
function onParserExecuteCommon(server, socket, parser, state, ret, d) {
resetSocketTimeout(server, socket, state);
if (ret instanceof Error) {
debug('parse error', ret);
socketOnError.call(socket, ret);
} else if (parser.incoming && parser.incoming.upgrade) {
长长的一个函数被我精简成这么几句话,重点很明显。
ret
就是从
socketOnData
传进来已解析的数据长度,但是在 C++ 代码中我们也看到了它还有可能是一个错误对象。所以在这个函数中一开始就做了一个判断,判断解析的结果是不是一个错误对象,如果是错误对象则调用
socketOnError()
。
function socketOnError(e) {
// Ignore further errors
this.removeListener('error', socketOnError);
this.on('error', () => {});
if (!this.server.emit('clientError', e, this))
this.destroy(e);
我们看到,如果真的不小心走到这一步的话,HTTP Server 对象会触发一个
clientError
事件。
整个事情串联起来了:
- 收到请求后会通过 http-parser 解析数据包;
-
GET /foo bar HTTP/1.1
会被解析出错并返回一个错误对象; -
错误对象会进入
if (ret instanceof Error)
条件分支并调用socketOnError()
函数; -
socketOnError()
函数中会对服务器触发一个clientError
事件;(this.server.emit('clientError', e, this)
) -
至此,HTTP Server 并不会走到你的那个
function(req, resp)
中去,所以不会有任何的数据被返回就结束了,也就解答了一开始的问题——收不到任何数据就请求结束。
这就是我要逐级进来看代码,而不是直达 http-parser 的原因了——
clientError
是一个关键。
4. 处理办法
要解决这个“Bug”其实不难,直接监听
clientError 事件
并做一些处理即可。
'use strict';
const http = require('http');
const server = http.createServer(function(req, resp) {
console.log('?');
resp.end('hello world');
}).on('clientError', function(err, sock) {
console.log('?');
sock.end('HTTP/1.1 400 Bad Request\r\n\r\n');
server.listen(5555);
注意:
由于运行到
clientError
事件时,并没有任何 Request 和 Response 的封装,你能拿到的是一个 Node.js 中原始的 Socket 对象,所以当你要返回数据的时候需要自己按照 HTTP 返回数据包的格式来输出。
这个时候再挥起你的小手试一下 CURL 吧:
$ curl 'http://127.0.0.1:5555/d d' -v
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5555 (#0)
> GET /d d HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/7.54.0
> Accept: */*
< HTTP/1.1 400 Bad Request
* no chunk, no close, no size. Assume close to signal end
* Closing connection 0
如愿以偿地输出了 400 状态码。
5. 引申
接下来我们要引申讨论的一个点是,为什么这货不是一个真正意义上的 Bug。
首先我们看看 Nginx 这么实现这个黑科技的吧。
5.1. Nginx 实现
打开 Nginx 源码的 相应位置 。
我们能看到它的状态机对于 URI 和 HTTP 协议声明中间多了一个中间状态,叫
sw_check_uri_http_09
,专门处理 URI 后面的空格。
在各种 URI 解析状态中,基本上都能找到这么一句话,表示若当前状态正则解析 URI 的各种状态并且遇到空格的话,则将状态改为
sw_check_uri_http_09
。
case sw_check_uri:
switch (ch) {
case ' ':
r->uri_end = p;
state = sw_check_uri_http_09;
break;
...
然后在
sw_check_uri_http_09
状态时会做一些检查:
case sw_check_uri_http_09:
switch (ch) {
case ' ':
break;
case CR:
r->http_minor = 9;
state = sw_almost_done;
break;
case LF:
r->http_minor = 9;
goto done;
case 'H':
r->http_protocol.data = p;
state = sw_http_H;
break;
default:
r->space_in_uri = 1;
state = sw_check_uri;
p--;
break;
break;
例如:
- 遇到空格则继续保持当前状态开始扫描下一位;
- 如果是换行符则设置默认 HTTP 版本并继续扫描;
-
如果遇到的是
H
才修改状态为sw_http_H
认为接下去开始 HTTP 版本扫描; -
如果是其它字符,则标明一下 URI 中有空格,然后将状态改回
sw_check_uri
,然后倒退回一格以sw_check_uri
继续扫描当前的空格。
在理解了这个“黑科技”后,我们很快能找到一个很好玩的点,开启你的 Nginx 并用 CURL 请求以下面的例子一下它看看吧:
$ curl 'http://xcoder.in:5555/d H' -v
* Trying 103.238.225.181...
* TCP_NODELAY set
* Connected to xcoder.in (103.238.225.181) port 5555 (#0)
> GET /d H HTTP/1.1
> Host: xcoder.in:5555
> User-Agent: curl/7.54.0
> Accept: */*
< HTTP/1.1 400 Bad Request
< Server: openresty/1.11.2.1
< Date: Tue, 12 Dec 2017 11:18:13 GMT
< Content-Type: text/html
< Content-Length: 179
< Connection: close
<head><title>400 Bad Request</title></head>