Node.js 中遇到含空格 URL 的神奇「Bug」——小範圍深刻 HTTP 協議

本文首發於知乎專欄螞蟻金服體驗科技html

做者 死月前端

首先聲明,我在「Bug」字眼上加了引號,天然是爲了說明它並不是一個真 Bug。node

問題拋出

昨天有個童鞋在看後臺監控的時候,忽然發現了一個錯誤:nginx

[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 協議去請求另外一臺服務器的時候,單方面被對方服務器斷開了鏈接——而且並無任何返回。git

開始重現

客戶端 CURL 指令

其實此次請求的一些貓膩很容易就能發現——在 URL 中有空格。因此咱們能簡化出一條最簡單的 CURL 指令:github

$ curl "http://foo/bar baz" -v
複製代碼

**注意:**不帶任何轉義。docker

最小 Node.js 源碼

好的,那麼接下去開始寫相應的最簡單的 Node.js HTTP 服務端源碼。c#

'use strict';

const http = require('http');

const server = http.createServer(function(req, resp) {
    console.log('🌚');
    resp.end('hello world');
});

server.listen(5555);
複製代碼

大功告成,啓動這段 Node.js 代碼,開始試試看上面的指令吧。api

若是你也正在跟着嘗試這件事情的話,你就會發現 Node.js 的命令行沒有輸出任何信息,尤爲是嘲諷的 '🌚',而在 CURL 的結果中,你將會看見:xcode

$ 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

發現了問題以後,就有另外一個問題值得思考了:就 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 xcoder.in left intact
/d d
複製代碼

厲害了,個人 Nginx

因而乎,理所固然,我暫時將這個事件定性爲 Node.js 的一個 Bug。

Node.js 源碼排查

認定了它是個 Bug 以後,我就開始了一向的看源碼環節——因爲這個 Bug 的復現條件比較明顯,我暫時將其定性爲「Node.js HTTP 服務端模塊在接到請求後解析 HTTP 數據包的時候解析 URI 時出了問題」。

http.js -> _http_server.js -> _http_common.js

源碼以 Node.js 8.9.2 爲準。

這裏先預留一下咱們能立刻想到的 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;
});
複製代碼

能看出來 parsersHTTPParser 的一條 Free List(效果相似於最簡易的動態內存池),每一個 Parser 在初始化的時候綁定上了各類回調函數。具體的一些回調函數就不細講了,有興趣的童鞋可自行翻閱。

這麼一來,鏈路就比較明晰了:

請求進來的時候,Server 對象會爲該次請求的 Socket 分配一個 HttpParser 對象,並調用其 execute() 函數進行解析,在解析完成後調用 onParserExecuteCommon() 函數。

node_http_parser.cc

咱們在 lib/_http_common.js 中能發現,HTTPParser 的實現存在於 src/node_http_parser.cc 中:

const binding = process.binding('http_parser');
const { methods, HTTPParser } = binding;
複製代碼

至於爲何 const binding = process.binding('http_parser') 就是對應到 src/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。它獨立的項目地址在 github.com/nodejs/http…,咱們本文中用的是 Node.js v8.9.2 中所依賴的源碼,應該會有誤差。

http-parser

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
複製代碼

源碼解析

話很少少,咱們之間前往 http-parser 的 http_parser.chttp_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_paths_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_paths_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_CONSTANTgotoerror 代碼塊。

至此,咱們基本上已經明白了緣由了:

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 就是已解析的數據長度。若是成功解析完,這個數據包理論上是等於這個數據包的完整長度,若不等則理論上說明確定是中途出錯提早返回。

回到 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_objnparsed)。

在這個文件中,眼尖的童鞋可能發現了,執行 Execute() 有好多處,這是由於實際上一個 HTTP 請求多是流式的,因此有時候可能會只拿到部分數據包。因此最後有一個結束符須要被確認。這也是爲何 http-parser 在解析的時候只能逐字解析而不能跳躍或者後退了。

回到 _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 是一個關鍵。

處理辦法

要解決這個「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 狀態碼。

引伸

接下來咱們要引伸討論的一個點是,爲何這貨不是一個真正意義上的 Bug。

首先咱們看看 Nginx 這麼實現這個黑科技的吧。

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
<
<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
複製代碼

怎麼樣?是否是發現結果跟以前的不同了——它竟然也返回了 400 Bad Request。

緣由爲什麼就交給童鞋們本身考慮吧。

RFC 2616 與 RFC 2396

那麼,爲何即便在 Nginx 支持空格 URI 的狀況下,我還說 Node.js 這個不算 Bug,而且指明 Nginx 是「黑科技」呢?

後來我去看了 HTTP 協議 RFC。

緣由在於 Network Working Group 的 RFC 2616,關於 HTTP 協議的規範。

在 RFC 2616 的 3.2.1 節中作了一些說明,它說了在 HTTP 協議中關於 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 節。裏面對於 Disallow 的 US-ASCII 字符作了解釋,其中有:

  • 控制符,指 ASCII 碼在 0x00-0x1F 範圍內以及 0x7F;

    控制符一般不可見;

  • 空格,指 0x20;

    空格不可控,如經由一些排版軟件轉錄後可能會有變化,而到了 HTTP 協議這層時,反正空格不推薦使用了,因此就索性用空格做爲首行分隔符了;

  • 分隔符,"<"">""#""%""\""

    # 將用於瀏覽器地址欄的 Hash;而 % 則會與 URI 轉義一同使用,因此不該單獨出如今 URI 中。

因而乎,HTTP 請求中,包體的 URI 彷佛本就不該該出現空格,而 Nginx 是一個黑魔法的姿式。

小結

嚯,寫得累死了。本次的一個探索基於了一個有空格非正常的 URI 經過 CURL 或者其它一些客戶端請求時,Node.js 出現的 Bug 狀態。

實際上發現這個 Bug 的時候,客戶端請求彷佛是由於那邊的開發者手抖,不當心將不該該拼接進來的內容給拼接到了 URL 中,相似於 $ rm -rf /

一開始我覺得這是 Node.js 的 Bug,在探尋以後發現是由於咱們本身沒用 Node.js HTTP Server 提供的 clientError 事件作正確的處理。而 Nginx 的正常請求則是它的黑科技。這些答案都能從 RFC 中尋找——再次體現了遇到問題看源碼看規範的重要性。

另,我本打算給 http-parser 也加上黑魔法,後來我快寫好的時候發現它是流式的,不少狀態無法在現有的體系中保留下來,最後放棄了,反正這也不算 Bug。不過在之後有時間的時候,感受仍是能夠好好整理一下代碼,好好修改一下給提個 PR 上去,以此自勉。

最後,求 fafa。

交流

若是你有更多的想法,或者想了解螞蟻金服的 Node.js、前端以及設計小夥伴們的更多姿式,能夠報名**首屆螞蟻體驗科技大會 SEE Conf**,好比有死馬大大的《Developer Experience First —— Techless Web Application 的理念與實踐》,還有青梔大大的《螞蟻開發者工具,服務螞蟻生態的移動研發 IDE》等等。

報名官網:SEE Conf · 螞蟻體驗科技大會

期待您的光臨。

相關文章
相關標籤/搜索