Node.js中遇到含空格URL的神奇“Bug”——小范圍深入HTTP協(xié)議
首先聲明,我在“Bug”字眼上加了引號,自然是為了說明它并非一個真 Bug。
問題拋出
昨天有個童鞋在看后臺監(jiān)控的時候,突然發(fā)現(xiàn)了一個錯誤:
- [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://..."
大概意思就是說:一臺服務(wù)器通過 HTTP 協(xié)議去請求另一臺服務(wù)器的時候,單方面被對方服務(wù)器斷開了連接——并且并沒有任何返回。
開始重現(xiàn)
客戶端 CURL 指令
其實這次請求的一些貓膩很容易就能發(fā)現(xiàn)——在 URL 中有空格。所以我們能簡化出一條最簡單的 CURL 指令:
- $ curl "http://foo/bar baz" -v
注意:不帶任何轉(zhuǎn)義。
最小 Node.js 源碼
好的,那么接下去開始寫相應(yīng)的最簡單的 Node.js HTTP 服務(wù)端源碼。
- 'use strict';
- const http = require('http');
- const server = http.createServer(function(req, resp) {
- console.log('🌚');
- resp.end('hello world');
- });
- server.listen(5555);
大功告成,啟動這段 Node.js 代碼,開始試試看上面的指令吧。
如果你也正在跟著嘗試這件事情的話,你就會發(fā)現(xiàn) Node.js 的命令行沒有輸出任何信息,尤其是嘲諷的 '🌚',而在 CURL 的結(jié)果中,你將會看見:
- $ 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。
Nginx
發(fā)現(xiàn)了問題之后,就有另一個問題值得思考了:就 Node.js 會出現(xiàn)這種情況呢,還是其它一些 HTTP 服務(wù)器也會有這種情況呢。
于是拿小白鼠 Nginx 做了個實驗。我寫了這么一個配置:
- server {
- listen 5555;
- location / {
- return 200 $uri;
- }
- }
接著也執(zhí)行一遍 CURL,得到了如下的結(jié)果:
- $ 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 xcoder.in left intact
- /d d
于是乎,理所當(dāng)然,我暫時將這個事件定性為 Node.js 的一個 Bug。
Node.js 源碼排查
認(rèn)定了它是個 Bug 之后,我就開始了一貫的看源碼環(huán)節(jié)——由于這個 Bug 的復(fù)現(xiàn)條件比較明顯,我暫時將其定性為“Node.js HTTP 服務(wù)端模塊在接到請求后解析 HTTP 數(shù)據(jù)包的時候解析 URI 時出了問題”。
http.js -> _http_server.js -> _http_common.js
源碼以 Node.js 8.9.2 為準(zhǔn)。
這里先預(yù)留一下我們能馬上想到的 node_http_parser.cc,而先講這幾個文件,是有原因的——這涉及到最后的一個應(yīng)對方式。
首先看看 lib/http.js 的相應(yīng)源碼:
- ...
- const server = require('_http_server');
- const { Server } = server;
- function createServer(requestListener) {
- return new Server(requestListener);
- }
那么,馬上進(jìn)入 lib/_http_server.js 看吧。
首先是創(chuàng)建一個 HttpParser 并綁上監(jiān)聽獲取到 HTTP 數(shù)據(jù)包后解析結(jié)果的回調(diào)函數(shù)的代碼:
- 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);
- }
從源碼中文我們能看到,當(dāng)一個 HTTP 請求過來的時候,監(jiān)聽函數(shù) connectionListener() 會拿著 Socket 對象加上一個 data 事件監(jiān)聽——一旦有請求連接過來,就去執(zhí)行 socketOnData() 函數(shù)。
而在 socketOnData() 函數(shù)中,做的主要事情就是 parser.execute(d) 來解析 HTTP 數(shù)據(jù)包,在解析完成后執(zhí)行一下回調(diào)函數(shù) 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(效果類似于最簡易的動態(tài)內(nèi)存池),每個 Parser 在初始化的時候綁定上了各種回調(diào)函數(shù)。具體的一些回調(diào)函數(shù)就不細(xì)講了,有興趣的童鞋可自行翻閱。
這么一來,鏈路就比較明晰了:
請求進(jìn)來的時候,Server 對象會為該次請求的 Socket 分配一個 HttpParser 對象,并調(diào)用其 execute() 函數(shù)進(jìn)行解析,在解析完成后調(diào)用 onParserExecuteCommon() 函數(shù)。
node_http_parser.cc
我們在 lib/_http_common.js 中能發(fā)現(xiàn),HTTPParser 的實現(xiàn)存在于 src/node_http_parser.cc 中:
- const binding = process.binding('http_parser');
- const { methods, HTTPParser } = binding;
至于為什么 const binding = process.binding('http_parser') 就是對應(yīng)到 src/node_http_parser.cc 文件,以及這一小節(jié)中下面的一些 C++ 源碼相關(guān)分析,不明白且有興趣的童鞋可自行去閱讀更深一層的源碼,或者網(wǎng)上搜索答案,或者我提前無恥硬廣一下我快要上市的書《Node.js:來一打 C++ 擴(kuò)展》——里面也有說明,以及我的有一場知乎 Live《深入理解 Node.js 包與模塊機(jī)制》。
總而言之,我們接下去要看的就是 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() 所對應(yīng)的函數(shù)就是 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);
- }
- }
首先進(jìn)入 Parser 的靜態(tài) Execute() 函數(shù),我們看到它把傳進(jìn)來的 Buffer 轉(zhuǎn)化為 C++ 下的 char* 指針,并記錄其數(shù)據(jù)長度,同時去執(zhí)行當(dāng)前調(diào)用的 parser 對象所對應(yīng)的 Execute() 函數(shù)。
在這個 Execute() 函數(shù)中,有個最重要的代碼,就是:
- size_t nparsed =
- http_parser_execute(&parser_, &settings, data, len);
這段代碼是調(diào)用真正解析 HTTP 數(shù)據(jù)包的函數(shù),它是 Node.js 這個項目的一個自研依賴,叫 http-parser。它獨立的項目地址在 https://github.com/nodejs/http-parser,我們本文中用的是 Node.js v8.9.2 中所依賴的源碼,應(yīng)該會有偏差。
http-parser
HTTP Request 數(shù)據(jù)包體
如果你已經(jīng)對 HTTP 包體了解了,可以略過這一節(jié)。
HTTP 的 Request 數(shù)據(jù)包其實是文本格式的,在 Raw 的狀態(tài)下,大概是以這樣的形式存在:
方法 URI HTTP/版本
頭1: 我是頭1
頭2: 我是頭2
簡單起見,這里就寫出最基礎(chǔ)的一些內(nèi)容,至于 Body 什么的大家自己找資料看吧。
上面的是什么意思呢?我們看看 CURL 的結(jié)果就知道了,實際上對應(yīng) curl ... -v 的中間輸出:
- GET /test HTTP/1.1
- Host: 127.0.0.1:5555
- User-Agent: curl/7.54.0
- Accept: */*
所以實際上大家平時在文章中、瀏覽器調(diào)試工具中看到的什么請求頭啊什么的,都是以文本形式存在的,以換行符分割。
而——重點來了,導(dǎo)致我們本文所述“Bug”出現(xiàn)的請求,它的請求包如下:
- 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
源碼解析
話不多少,我們之間前往 http-parser 的 http_parser.c 看 http_parser_execute () 函數(shù)中的狀態(tài)機(jī)變化。
從源碼中文我們能看到,http-parser 的流程是從頭到尾以 O(n) 的時間復(fù)雜度對字符串逐字掃描,并且不后退也不往前跳。
那么掃描到每個字符的時候,都有屬于當(dāng)前的一個狀態(tài),如“正在掃描處理 uri”、“正在掃描處理 HTTP 協(xié)議并且處理到了 H”、“正在掃描處理 HTTP 協(xié)議并且處理到了 HT”、“正在掃描處理 HTTP 協(xié)議并且處理到了 HTT”、“正在掃描處理 HTTP 協(xié)議并且處理到了 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;
- }
在掃描的時候,如果當(dāng)前狀態(tài)是 URI 相關(guān)的(如 s_req_path、s_req_query_string 等),則執(zhí)行一個子 switch,里面的處理如下:
- 若當(dāng)前字符是空格,則將狀態(tài)改變?yōu)?s_req_http_start 并認(rèn)為 URI 已經(jīng)解析好了,通過宏 CALLBACK_DATA() 觸發(fā) URI 解析好的事件;
- 若當(dāng)前字符是換行符,則說明還在解析 URI 的時候就被換行了,后面就不可能跟著 HTTP 協(xié)議版本的申明了,所以設(shè)置默認(rèn)的 HTTP 版本為 0.9,并修改當(dāng)前狀態(tài),最后認(rèn)為 URI 已經(jīng)解析好了,通過宏 CALLBACK_DATA() 觸發(fā) URI 解析好的事件;
- 其余情況(所有其它字符)下,通過調(diào)用 parse_url_char() 函數(shù)來解析一些東西并更新當(dāng)前狀態(tài)。(因為哪怕是在解析 URI 狀態(tài)中,也還有各種不同的細(xì)分,如 s_req_path、s_req_query_string )
這里的重點還是當(dāng)狀態(tài)為解析 URI 的時候遇到了空格的處理,上面也解釋過了,一旦遇到這種情況,則會認(rèn)為 URI 已經(jīng)解析好了,并且將狀態(tài)修改為 s_req_http_start。也就是說,有“Bug”的那個數(shù)據(jù)包
GET /foo bar HTTP/1.1 在解析到 foo 后面的空格的時候它就將狀態(tài)改為 s_req_http_start 并且認(rèn)為 URI 已經(jīng)解析結(jié)束了。
好的,接下來我們看看 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:
- ...
如代碼所見,若當(dāng)前狀態(tài)為 s_req_http_start,則先判斷當(dāng)前字符是不是合標(biāo)。因為就 HTTP 請求包體的格式來看,如果 URI 解析結(jié)束的話,理應(yīng)出現(xiàn)類似 HTTP/1.1 的這么一個版本申明。所以這個時候 http-parser 會直接判斷當(dāng)前字符是否為 H。
- 若是 H,則將狀態(tài)改為 s_req_http_H 并繼續(xù)掃描循環(huán)的下一位,同理在 s_req_http_H 下若合法狀態(tài)就會變成 s_req_http_HT,以此類推;
+若是空格,則認(rèn)為是多余的空格,那么當(dāng)前狀態(tài)不做任何改變,并繼續(xù)下一個掃描;
- 但如果當(dāng)前字符既不是空格也不是 H,那么好了,http-parser 直接認(rèn)為你的請求包不合法,將你本次的解析設(shè)置錯誤 HPE_INVALID_CONSTANT 并 goto 到 error 代碼塊。
至此,我們基本上已經(jīng)明白了原因了:
http-parser 認(rèn)為在 HTTP 請求包體中,第一行的 URI 解析階段一旦出現(xiàn)了空格,就會認(rèn)為 URI 解析完成,繼而解析 HTTP 協(xié)議版本。但若此時緊跟著的不是 HTTP 協(xié)議版本的標(biāo)準(zhǔn)格式,http-parser 就會認(rèn)為你這是一個 HPE_INVALID_CONSTANT 的數(shù)據(jù)包。
不過,我們還是繼續(xù)看看它的 error 代碼塊吧:
- error:
- if (HTTP_PARSER_ERRNO(parser) == HPE_OK) {
- SET_ERRNO(HPE_UNKNOWN);
- }
- RETURN(p - data);
這段代碼中首先判斷一下當(dāng)跳到這段代碼的時候有沒有設(shè)置錯誤,若沒有設(shè)置錯誤則將錯誤設(shè)置為未知錯誤(HPE_UNKNOWN),然后返回已解析的數(shù)據(jù)包長度。
p 是當(dāng)前解析字符指針,data 是這個數(shù)據(jù)包的起始指針,所以 p - data 就是已解析的數(shù)據(jù)長度。如果成功解析完,這個數(shù)據(jù)包理論上是等于這個數(shù)據(jù)包的完整長度,若不等則理論上說明肯定是中途出錯提前返回。
回到 node_http_parser.cc
看完了 http-parser 的原理后,很多地方茅塞頓開?,F(xiàn)在我們回到它的調(diào)用地 node_http_parser.cc 繼續(xù)閱讀吧。
- 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);
- }
從調(diào)用處我們能看見,在執(zhí)行完 http_parser_execute() 后有一個判斷,若當(dāng)前請求不是 upgrade 請求(即請求頭中有說明 Upgrade,通常用于 WebSocket),并且解析長度不等于原數(shù)據(jù)包長度(前文說了這種情況屬于出錯了)的話,那么進(jìn)入中間的錯誤代碼塊。
在錯誤代碼塊中,先 HTTP_PARSER_ERRNO(&parser_) 拿到錯誤碼,然后通過 Exception::Error() 生成錯誤對象,將錯誤信息塞進(jìn)錯誤對象中,最后返回錯誤對象。
如果沒錯,則返回解析長度(nparsed_obj 即 nparsed)。
在這個文件中,眼尖的童鞋可能發(fā)現(xiàn)了,執(zhí)行 Execute() 有好多處,這是因為實際上一個 HTTP 請求可能是流式的,所以有時候可能會只拿到部分?jǐn)?shù)據(jù)包。所以最后有一個結(jié)束符需要被確認(rèn)。這也是為什么 http-parser 在解析的時候只能逐字解析而不能跳躍或者后退了。
回到 _http_server.js
我們把 Parser::Execute() 也就是 JavaScript 代碼中的 parser.execute() 給搞清楚后,我們就能回到 _http_server.js 看代碼了。
前文說了,socketOnData 在解析完數(shù)據(jù)包后會執(zhí)行 onParserExecuteCommon 函數(shù),現(xiàn)在就來看看這個 onParserExecuteCommon() 函數(shù)。
- 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) {
- ...
- }
- }
長長的一個函數(shù)被我精簡成這么幾句話,重點很明顯。ret 就是從 socketOnData 傳進(jìn)來已解析的數(shù)據(jù)長度,但是在 C++ 代碼中我們也看到了它還有可能是一個錯誤對象。所以在這個函數(shù)中一開始就做了一個判斷,判斷解析的結(jié)果是不是一個錯誤對象,如果是錯誤對象則調(diào)用 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 對象會觸發(fā)一個 clientError 事件。
整個事情串聯(lián)起來了:
- 收到請求后會通過 http-parser 解析數(shù)據(jù)包;
- GET /foo bar HTTP/1.1 會被解析出錯并返回一個錯誤對象;
- 錯誤對象會進(jìn)入 if (ret instanceof Error) 條件分支并調(diào)用 socketOnError() 函數(shù);
- socketOnError() 函數(shù)中會對服務(wù)器觸發(fā)一個 clientError 事件;(this.server.emit('clientError', e, this))
- 至此,HTTP Server 并不會走到你的那個 function(req, resp) 中去,所以不會有任何的數(shù)據(jù)被返回就結(jié)束了,也就解答了一開始的問題——收不到任何數(shù)據(jù)就請求結(jié)束。
這就是我要逐級進(jìn)來看代碼,而不是直達(dá) http-parser 的原因了——clientError 是一個關(guān)鍵。
處理辦法
要解決這個“Bug”其實不難,直接監(jiān)聽 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 對象,所以當(dāng)你要返回數(shù)據(jù)的時候需要自己按照 HTTP 返回數(shù)據(jù)包的格式來輸出。
這個時候再揮起你的小手試一下 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
如愿以償?shù)剌敵隽?400 狀態(tài)碼。
引申
接下來我們要引申討論的一個點是,為什么這貨不是一個真正意義上的 Bug。
首先我們看看 Nginx 這么實現(xiàn)這個黑科技的吧。
Nginx 實現(xiàn)
打開 Nginx 源碼的相應(yīng)位置。
我們能看到它的狀態(tài)機(jī)對于 URI 和 HTTP 協(xié)議聲明中間多了一個中間狀態(tài),叫 sw_check_uri_http_09,專門處理 URI 后面的空格。
在各種 URI 解析狀態(tài)中,基本上都能找到這么一句話,表示若當(dāng)前狀態(tài)正則解析 URI 的各種狀態(tài)并且遇到空格的話,則將狀態(tài)改為 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 狀態(tài)時會做一些檢查:
- 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;
例如:
- 遇到空格則繼續(xù)保持當(dāng)前狀態(tài)開始掃描下一位;
- 如果是換行符則設(shè)置默認(rèn) HTTP 版本并繼續(xù)掃描;
- 如果遇到的是 H 才修改狀態(tài)為 sw_http_H 認(rèn)為接下去開始 HTTP 版本掃描;
- 如果是其它字符,則標(biāo)明一下 URI 中有空格,然后將狀態(tài)改回 sw_check_uri,然后倒退回一格以 sw_check_uri 繼續(xù)掃描當(dāng)前的空格。
在理解了這個“黑科技”后,我們很快能找到一個很好玩的點,開啟你的 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
- <
- <html>
- <head><title>400 Bad Request</title></head>
- <body bgcolor="white">
- <center><h1>400 Bad Request</h1></center>
- <hr><center>openresty/1.11.2.1</center>
- </body>
- </html>
- * Closing connection 0
怎么樣?是不是發(fā)現(xiàn)結(jié)果跟之前的不一樣了——它居然也返回了 400 Bad Request。
原因為何就交給童鞋們自己考慮吧。
RFC 2616 與 RFC 2396
那么,為什么即使在 Nginx 支持空格 URI 的情況下,我還說 Node.js 這個不算 Bug,并且指明 Nginx 是“黑科技”呢?
后來我去看了 HTTP 協(xié)議 RFC。
原因在于 Network Working Group 的 RFC 2616,關(guān)于 HTTP 協(xié)議的規(guī)范。
在 RFC 2616 的 3.2.1 節(jié)中做了一些說明,它說了在 HTTP 協(xié)議中關(guān)于 URI 的文法和語義參照了 RFC 2396。
URIs in HTTP can be represented in absolute form or relative to some known base URI, depending upon the context of their use. The two forms are differentiated by the fact that absolute URIs always begin with a scheme name followed by a colon. For definitive information on URL syntax and semantics, see "Uniform Resource Identifiers (URI): Generic Syntax and Semantics," RFC 2396 (which replaces RFCs 1738 and RFC 1808). This specification adopts the definitions of "URI-reference", "absoluteURI", "relativeURI", "port", "host","abs_path", "rel_path", and "authority" from that specification.
而在 RFC 2396 中,我們同樣找到了它的 2.4.3 節(jié)。里面對于 Disallow 的 US-ASCII 字符做了解釋,其中有:
- 控制符,指 ASCII 碼在 0x00-0x1F 范圍內(nèi)以及 0x7F;
- 控制符通常不可見;
- 空格,指 0x20;
- 空格不可控,如經(jīng)由一些排版軟件轉(zhuǎn)錄后可能會有變化,<span style="color: #ccc;">而到了 HTTP 協(xié)議這層時,反正空格不推薦使用了,所以就索性用空格作為首行分隔符了;</span>
- 分隔符,"<"、">"、"#"、"%"、"\""。
如 # 將用于瀏覽器地址欄的 Hash;而 % 則會與 URI 轉(zhuǎn)義一同使用,所以不應(yīng)單獨出現(xiàn)在 URI 中。
于是乎,HTTP 請求中,包體的 URI 似乎本就不應(yīng)該出現(xiàn)空格,而 Nginx 是一個黑魔法的姿勢。
小結(jié)
嚯,寫得累死了。本次的一個探索基于了一個有空格非正常的 URI 通過 CURL 或者其它一些客戶端請求時,Node.js 出現(xiàn)的 Bug 狀態(tài)。
實際上發(fā)現(xiàn)這個 Bug 的時候,客戶端請求似乎是因為那邊的開發(fā)者手抖,不小心將不應(yīng)該拼接進(jìn)來的內(nèi)容給拼接到了 URL 中,類似于 $ rm -rf /。
一開始我以為這是 Node.js 的 Bug,在探尋之后發(fā)現(xiàn)是因為我們自己沒用 Node.js HTTP Server 提供的 clientError 事件做正確的處理。而 Nginx 的正常請求則是它的黑科技。這些答案都能從 RFC 中尋找——再次體現(xiàn)了遇到問題看源碼看規(guī)范的重要性。
另,我本打算給 http-parser 也加上黑魔法,后來我快寫好的時候發(fā)現(xiàn)它是流式的,很多狀態(tài)沒法在現(xiàn)有的體系中保留下來,最后放棄了,反正這也不算 Bug。不過在以后有時間的時候,感覺還是可以好好整理一下代碼,好好修改一下給提個 PR 上去,以此自勉。