一個有趣的nginx HTTP 400響應問題分析

2022-11-30 06:00:24

背景

之前在一次不規範HTTP請求引發的nginx響應400問題分析與解決 中寫過使用者端query引數未urlencode導致的400問題,當時的結論是:

對於query引數帶空格的請求,由於其不符合HTTP規範,golang的net/http庫無法識別會直接報錯400,而nginx和使用uwsgi與nginx互動的api主服務卻可以相容,可以正常處理。
最終的臨時解決方案是:在nginx層根據query 引數是否包含空格決定是轉發到golang的log server或api主服務。

本來以為這事就這麼結束了,結果最近查詢nginx的錯誤log,居然又發現少部分400錯誤,最終定位也是因為query 引數包含空格,而且這次報錯是直接在nginx層返回400,後面的轉發判定邏輯都不會被觸發,於是很神奇的發現了兩類空格導致的400問題:

  1. 第一類是之前解決了的nginx可以相容識別,但golang 網路庫無法識別會報400的含空格請求,舉例入下:
curl 'http://test.myexample123.com/test?appname=demoapp&phonetype=android&device_type=android&osn=Android OS 10 / API-29 (HONORHLK-AL00/102.0.0.270C00)&osv=Android OS 10 / API-29 (HONORHLK-AL00/102.0.0.270C00)&channel=Google Play&model=HUAWEIHLK-AL00&build=Android OS 10 / API-29 (HONORHLK-AL00/102.0.0.270C00)'
{"status": 1, "data": {"test": "ok"}}
  1. 第二類是這次新發現的nginx層直接返回400的含空格請求,並且還發發現該類報錯很多都是來源於華為手機,如下可看出其400響應為nginx直接返回:
curl 'http://test.myexample123.com/test?appname=demoapp&phonetype=android&device_type=android&osn=Android OS 10 / API-29 (HONORHLK-AL00/102.0.0.270C00)&osv=Android OS 10 / API-29 (HONORHLK-AL00/102.0.0.270C00)&channel=Google Play&model=HUAWEI HLK-AL00&build=Android OS 10 / API-29 (HONORHLK-AL00/102.0.0.270C00)'
<html>
<head><title>400 Bad Request</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx/1.16.1</center>
</body>
</html>

乍看之下其請求引數完全沒看出區別,無論哪類問題只要去掉了空格就不會有問題了,難不成nginx對華為手機還有歧視不成(>_<)。

問題定位

兩類問題都是由於query引數帶空格引起的,最終通過二分法試錯確認了其關鍵區別:如果query引數中包含" H"--即空格+H的組合,nginx層即會直接報錯返回400,而如果不包含" H"這一組合,nginx層將能相容處理--這解釋了為何大部分400請求來自華為手機,因為華為手機model引數很多都是"HUAWEI HRY-AL00"這類取值,即包含了" H"這一子串,看起來" H"這個組合在nginx內部有特殊含義,華為手機給撞槍口上了。
那" H"在nginx中到底有什麼特殊含義呢?又到了探究原始碼的時候了,通過拜讀原始碼最終在ngx_http_parse.c 中負責解析http 請求行的ngx_http_parse_request_line 函數中找到了原因,如下

103 ngx_int_t
 104 ngx_http_parse_request_line(ngx_http_request_t *r, ngx_buf_t *b)
 105 {
 106     u_char  c, ch, *p, *m;
 107     enum {
 108         sw_start = 0,
 109         sw_method,
 110         sw_spaces_before_uri,
 111         sw_schema,
 112         sw_schema_slash,
 113         sw_schema_slash_slash,
 114         sw_host_start,
 115         sw_host,
 116         sw_host_end,
 117         sw_host_ip_literal,
 118         sw_port,
 119         sw_host_http_09,
 120         sw_after_slash_in_uri,
 121         sw_check_uri,
 122         sw_check_uri_http_09,
 123         sw_uri,
 124         sw_http_09,
 125         sw_http_H,
 126         sw_http_HT,
 127         sw_http_HTT,
 128         sw_http_HTTP,
 129         sw_first_major_digit,
 130         sw_major_digit,
 131         sw_first_minor_digit,
 132         sw_minor_digit,
 133         sw_spaces_after_digit,
 134         sw_almost_done
 135     } state;
 136
 137     state = r->state;
 138
 139     for (p = b->pos; p < b->last; p++) {
 140         ch = *p;
 141
 142         switch (state) {
 143
 144         /* HTTP methods: GET, HEAD, POST */
 145         case sw_start:
 146             r->request_start = p;
 147
 148             if (ch == CR || ch == LF) {
 149                 break;
 150             }
 ...
 486         /* check "/.", "//", "%", and "\" (Win32) in URI */
 487         case sw_after_slash_in_uri:
 488
 489             if (usual[ch >> 5] & (1U << (ch & 0x1f))) {
 490                 state = sw_check_uri;
 491                 break;
 492             }
 493
 494             switch (ch) {
 495             case ' ':
 496                 r->uri_end = p;
 497                 state = sw_check_uri_http_09; 
 498                 break;
 499             case CR:
 500                 r->uri_end = p;
 501                 r->http_minor = 9;
 502                 state = sw_almost_done;
 503                 break;
 ...
 606         /* space+ after URI */
 607         case sw_check_uri_http_09:
 608             switch (ch) {
...
 618             case 'H':
 619                 r->http_protocol.data = p;
 620                 state = sw_http_H;
 621                 break;
 622             default:
 623                 r->space_in_uri = 1;
 624                 state = sw_check_uri;
 625                 p--;
 626                 break;
 627             }
 628             break;
 ...
 684         case sw_http_H:
 685             switch (ch) {
 686             case 'T':
 687                 state = sw_http_HT;
 688                 break;
 689             default:
 690                 return NGX_HTTP_PARSE_INVALID_REQUEST;
 691             }
 692             break;
 693
 694         case sw_http_HT:
 695             switch (ch) {
 696             case 'T':
 697                 state = sw_http_HTT;
 698                 break;
 699             default:
 700                 return NGX_HTTP_PARSE_INVALID_REQUEST;
 701             }
 702             break;
 703
 704         case sw_http_HTT:
 705             switch (ch) {
 706             case 'P':
 707                 state = sw_http_HTTP;
 708                 break;
 709             default:
 710                 return NGX_HTTP_PARSE_INVALID_REQUEST;
 711             }
 712             break;
 ...

如上ngx_http_parse_request_line函數解析請求行原理為通過for迴圈逐個遍歷字元,內部使用大量switch語句實現了一個狀態機進行解析。
當解析到sw_after_slash_in_uri分支的case ' '(495行)時,會設定狀態state=sw_check_uri_http_09,而後在sw_check_uri_http_09分支的case 'H'(618行)設定state=sw_http_H,而sw_http_H其實是HTTP protocol的解析分支,其負責解析出類似HTTP/1.1 這樣的內容,所以在分支sw_http_H(684行)其期待的正確字元應該是HTTP/1.1的 第二個字元T,而後進入case sw_http_HT期待解析HTTP/1.1的第三個字元T,以此類推最終逐個解析完成整個protocol字串,但是在sw_http_H分支中若沒有解析到期望的字元T,其預設行為就是直接返回NGX_HTTP_PARSE_INVALID_REQUEST,也就是400常數了。
簡單來說,nginx在解析請求行時,若在query引數中遇到了" H"的組合會導致狀態機認為已經進入protocol欄位的解析分支,當碰到不識別的字串則認為格式錯誤,會直接返回400,而如果query引數中雖然包含未跳脫空格但卻沒有" H"組合,nginx的這個請求行解析狀態機倒還能夠一定程度相容此類錯誤,將請求正常轉發給upstream server處理。
當然,無論nginx能不能相容query引數未跳脫空格,最正確的做法還是使用者端應該一開始就保證所有query引數都經過必要urlencode再進行使用,這樣壓根就不會有這麼一堆么蛾子。

轉載請註明出處:https://www.cnblogs.com/AcAc-t/p/nginx_http_400_for_space_H.html