写点什么

一次不规范 HTTP 请求引发的 nginx 响应 400 问题分析与解决

作者:高端章鱼哥
  • 2023-06-28
    福建
  • 本文字数:5158 字

    阅读完需:约 17 分钟

背景

最近分析数据偶然发现 nginx log 中有一批用户所有的 HTTP POST log 上报请求均返回 400,没有任何 200 成功记录,由于只占整体请求的不到 0.5%,所以之前也一直没有触发监控报警,而且很奇怪的是只对于 log 上报的 POST 接口会存在这种特定用户全部 400 的情况,而对于其他接口无论 POST 还是 GET 均没有此类问题。


进一步分析 log 发现其实对某些地区的用户请求,这个比例甚至超过了 10%,于是花时间跟进了一下,最终发现源于部分机型客户端发出的 HTTP 请求格式不规范导致,这里记录一下分析过程、原因以及最终解决方案。

问题分析

常见 nginx 400 原因

搜寻网上资料,发现一般可能有以下几个原因会导致 nginx 响应 400:


  1. request_uri 过长超过 nginx 配置大小

  2. cookie 或者 header 过大超过 nginx 配置大小

  3. 空 HOST 头

  4. content_length 和 body 长度不一致


这些错误其实都是发生在 nginx 这一层,即 nginx 处理时认为客户端请求格式错误,于是直接返回 400,不会向 upstream server 转发请求,因而 upstream server 对这些错误请求其实完全是无感知的。


而这次根据 nginx log 分析,可以看到 nginx 其实有向 upstream server 转发请求--upstream_addr 已经是 upstream server 有效地址,所以 400 实际应当是 upstream server 返回的,而不是 nginx 直接返回,这说明至少 nginx 这一层认为请求格式是没问题的。

实际 nginx 400 log 分析

截取部分线上部分用户的错误日志,其大体样式如下

127.0.0.1:63646	-	24/Apr/2022:00:50:07 +0900	127.0.0.1:1080	0.000	0.000	POST /log/report?appd=abc.demo.android&appname=abcdemo&v=1.0&langes=zh-CN&phonetype=android&device_type=android&osn=Android OS 10 / API-29 (QKQ1.190825.002/V12.0.6.0.QFKCNXM)&channel=Google Play&build=Android OS 10 / API-29 (QKQ1.190825.002/V12.0.6.0.QFKCNXM)&resolution=1080x2340&ts=1650636192534 HTTP/1.1	400	50	-	curl/7.52.1	-	0.000	0.000	127.0.0.1	1563	2021
复制代码


日志分析可以发现大部分 400 请求都有一个问题:其 query 参数并未经过 urlencode,比如可以很明显看到其参数 channel=Google Play 中的空格并未转码成 %20,直觉上推断这应该和 400 的原因有直接关系。

试错

为了验证未转码 query 参数是否是导致 400 的直接原因,简单通过 curl 构造几个测试 http 请求:


# 无空格curl -v 'http://127.0.0.1/log/report?appd=abc.demo.android&appname=abcdemo&v=1.0&langes=zh-CN&phonetype=android&channel=Google%20Play' -d @test.json*   Trying 127.0.0.1...* TCP_NODELAY set* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0)> POST /log/report?appd=abc.demo.android&appname=abcdemo&v=1.0&langes=zh-CN&phonetype=android&channel=Google%20Play HTTP/1.1> Host: 127.0.0.1> User-Agent: curl/7.52.1> Accept: */*> Content-Length: 1563> Content-Type: application/x-www-form-urlencoded> Expect: 100-continue>< HTTP/1.1 100 Continue* We are completely uploaded and fine< HTTP/1.1 200 OK< Server: nginx/1.16.1< Date: Sat, 23 Apr 2022 15:54:53 GMT< Content-Type: application/json< Content-Length: 22< Connection: keep-alive<* Curl_http_done: called premature == 0* Connection #0 to host 127.0.0.1 left intact# 有空格curl -v 'http://127.0.0.1/log/report?appd=abc.demo.android&appname=abcdemo&v=1.0&langes=zh-CN&phonetype=android&channel=Google Play' -d @test.json*   Trying 127.0.0.1...* TCP_NODELAY set* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0)> POST /log/report?appd=abc.demo.android&appname=abcdemo&v=1.0&langes=zh-CN&phonetype=android&channel=Google Play HTTP/1.1> Host: 127.0.0.1> User-Agent: curl/7.52.1> Accept: */*> Content-Length: 1563> Content-Type: application/x-www-form-urlencoded> Expect: 100-continue>< HTTP/1.1 100 Continue* We are completely uploaded and fine< HTTP/1.1 400 Bad Request< Server: nginx/1.16.1< Date: Sat, 23 Apr 2022 15:55:14 GMT< Content-Type: text/plain; charset=utf-8< Transfer-Encoding: chunked< Connection: keep-alive<* Curl_http_done: called premature == 0* Connection #0 to host 127.0.0.1 left intact
复制代码


发现凡是带空格的请求 upstream server 均会直接返回 400,这里可以推断 query 参数未 urlencode 是 400 问题的直接原因了,但是为什么未转码会导致 400 呢?怎么从 HTTP 原理上解释这个现象?为了找到答案,需要回顾了一下 HTTP 协议标准。

HTTP 请求规范格式

HTTP 的请求消息格式如下:



如上图所示,作为一种文本协议,对 HTTP 请求消息中不同部分的区别、拆分完全是基于空格 、回车符\r、换行符\n 这些字符标记进行的,对于第一行的三个部分请求方法、URL 和协议版本的拆分即是根据空格进行 split。


分析查到的 400 HTTP 请求,可以发现由于 query 参数未 urlencode,导致其中会出现空格,这时严格来说这个请求已经不符合 HTTP 规范了,因为此时第一行再根据空格可以 split 出超过 3 部分,无法与 method、URL、version 再一一对应,从语义上来说此时直接返回 400 是合理处理逻辑。


实际处理中,面对这种情况,有的组件能兼容处理--把 split 的首部和尾部分别作为 method 与 version,而中间剩余部分统一作为 URL,比如 nginx 即兼容了这种不规范格式,但是很多组件并不能兼容处理这种情况--毕竟这并不符合 HTTP 规范,比如 charles 抓包此种请求会出错、golang 的 net/http 库、Django 的 http 模块收到这类请求都会报 400...

golang net/http 解析 HTTP 代码分析

负责日志上报的 upstream server 是 golang 实现的 logsvc,其使用标准卡库 net/http 处理 HTTP 请求,进一步探究一下该标准库是怎么解析 HTTP 请求的,以确认错误原因。


根据 golang 源码,可以发现其 HTTP 请求解析的路径为 http.ListenAndServe => http.Serve => serve => readRequest.... 其解析 HTTP 请求头的逻辑即位于 readRequest 函数中。


readRequest 部分代码如下:


// file: net/http/request.go...1009 func readRequest(b *bufio.Reader, deleteHostHeader bool) (req *Request, err error) {1010     tp := newTextprotoReader(b)1011     req = new(Request)10121013     // First line: GET /index.html HTTP/1.01014     var s string1015     if s, err = tp.ReadLine(); err != nil {1016         return nil, err1017     }1018     defer func() {1019         putTextprotoReader(tp)1020         if err == io.EOF {1021             err = io.ErrUnexpectedEOF1022         }1023     }()10241025     var ok bool1026     req.Method, req.RequestURI, req.Proto, ok = parseRequestLine(s)1027     if !ok {1028         return nil, &badStringError{"malformed HTTP request", s}1029     }1030     if !validMethod(req.Method) {1031         return nil, &badStringError{"invalid method", req.Method}1032     }1033     rawurl := req.RequestURI1034     if req.ProtoMajor, req.ProtoMinor, ok = ParseHTTPVersion(req.Proto); !ok {1035         return nil, &badStringError{"malformed HTTP version", req.Proto}1036     }...
复制代码


可以看到 readRequest 中先通过 parseRequestLine 解析出首行的 method, URL 与 Proto 三个字段,然后通过 ParseHTTPVersion 解析 version 是否正确,不正确则报错{"malformed HTTP version", 最终会导致响应 400。


parseRequestLine 代码如下:


... 966 // parseRequestLine parses "GET /foo HTTP/1.1" into its three parts. 967 func parseRequestLine(line string) (method, requestURI, proto string, ok bool) { 968     s1 := strings.Index(line, " ") 969     s2 := strings.Index(line[s1+1:], " ") 970     if s1 < 0 || s2 < 0 { 971         return 972     } 973     s2 += s1 + 1 974     return line[:s1], line[s1+1 : s2], line[s2+1:], true 975 }
复制代码


可以看到 parseRequestLine 的解析代码是通过查找第 0 个、第 1 个空格 index,然后直接基于 slice 语法将其切成了 method、requestURI、proto 三部分,如果 requestURI 中包含额外空格,会导致 proto 取值实际变为第一个空格之后的所有字符,比如"POST abc/?x=o space d HTTP/1.1"会被解析为:method=POST, requestURI=abc/?x=0, proto=" space d HTTP/1.1",这会导致下一步 ParseHTTPVersion 解析出错。


ParseHTTPVersion 代码如下,可以发现之前 parseRequestLine 解析得到的 version 字段如果不合法,则会返回错误:


... 769 // ParseHTTPVersion parses an HTTP version string. 770 // "HTTP/1.0" returns (1, 0, true). 771 func ParseHTTPVersion(vers string) (major, minor int, ok bool) { 772     const Big = 1000000 // arbitrary upper bound 773     switch vers { 774     case "HTTP/1.1": 775         return 1, 1, true 776     case "HTTP/1.0": 777         return 1, 0, true 778     } 779     if !strings.HasPrefix(vers, "HTTP/") { 780         return 0, 0, false 781     } 782     dot := strings.Index(vers, ".") 783     if dot < 0 { 784         return 0, 0, false 785     } 786     major, err := strconv.Atoi(vers[5:dot]) 787     if err != nil || major < 0 || major > Big { 788         return 0, 0, false 789     } 790     minor, err = strconv.Atoi(vers[dot+1:]) 791     if err != nil || minor < 0 || minor > Big { 792         return 0, 0, false 793     } 794     return major, minor, true 795 }
复制代码

解决方案

首先要做的是先和客户端对齐问题,客户端确认部分机型上其调用 unity 的网络库方法未能对其 query 参数正常 urlencode,新版本将在 unity 网络库之上增加额外代码保证所有参数必须 urlencode,使其符合 HTTP 规范。


而后进一步考虑可否先临时兼容处理线上已有的异常请求,防止新版本覆盖修复前这部分异常用户 log 上报数据的持续丢失,针对兼容考虑了以下几个方案

尝试三方 HTTP golang 库 gin && echo

由于日志服务由独立的 golang server 负责,其代码逻辑很简单:只是对 log 的 POST 请求的 body 进行解压缩、解析、写入 kafka,并无其他额外逻辑,改动成本较低,因此先考虑了替换 net/http 为其他三方库看是否能解决问题。


先后尝试了流行的 gin 和 echo 库发现都报 400,忍不住又探究了其源码,结果发现这两个库内部其实都调用了 net/http 的 ListenAndServer 和 Serve 方法,其前面的解析逻辑就是 net/http 对应代码负责的,因而自然也会报 400。

nginx lua/perl 脚本更改 query 参数

想到的另一个可能方法是在 nginx 层使用 lua/perl 脚本对传入的未 urlencode 的 request_uri 参数进行 urlencode 后再发给 upstream server,但是发现线上 nginx 编译时并未集成 lua、perl 的模块。要采用此种方法则只能:


  1. 要么重新编译整个 nginx 替换原 nginx

  2. 或者采用动态加载的方式单独编译 perl、lua 模块后使用 nginx 动态加载


考虑到本人作为 RD 而非专业 nginx OP 人员,和对线上影响的风险不轻易尝试。

nginx 将 log/report 路由至可兼容空格未转码 HTTP 请求的 server

开头提到过对于待空格的异常请求,只有 log 上报 POST 接口会返回 400,其他接口都返回正常,这其实是因为在 nginx 转发时对正常的业务接口和 log 接口进行了拆分,log/report 接口会单独转发到独立的 golang logsvc 服务,而正常业务请求均会转发给 python 的主 api 服务。


回顾当初之所以会拆分一个单独的 golang server 负责 app log 上报的解析和写 kafka,而不再和其他接口逻辑一样都由主 api 服务负责,主要是两个原因:


  1. Pythono 写的 api 主服务相对效率较低,对于频繁、大量的 log 上报可能耗费过多资源,速度也较慢

  2. 避免 log 上报类请求影响其他正常的业务请求响应速度,将业务逻辑与日志上报两者解耦


当前 logsvc 无法处理的此种情况,使用 uwsgi 协议与 nginx 交互的 api 主服务却可以正常解析,因而在 nginx 添加如下临时配置:


    location /log/report {        include proxy_params;        if ( $args !~ "^(.*) (.*)$" ) {      proxy_pass http://test_log_stream;            break;        }        include uwsgi_params;        uwsgi_pass test_api_stream;    }
复制代码


即通过正则匹配 query 参数(args)中若不存在空格直接交由 logsvc 处理,存在空格则交由使用 uwsgi 协议的 api 主服务处理,由于此类异常请求仅占整体请求的不到 0.5%,之前考虑的拆分架构依然 work,只是对于少量的异常请求先通过 api 主服务进行兼容处理。


转载请注明出处,原文地址: https://www.cnblogs.com/AcAc-t/p/nginx_400_problem_for_not_encode_http_request.html

发布于: 刚刚阅读数: 3
用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
一次不规范HTTP请求引发的nginx响应400问题分析与解决_nginx_高端章鱼哥_InfoQ写作社区