
网关转过来的一批请求里冒出一串 400,正文是空的,服务端日志里只有一行 clientError。报文内容本身没毛病,问题出在描述报文长度的两个头同时出现了。这类报文在规范里是明确不合法的,动手拦它的是解析层,业务代码一行都没跑到。
规范原文怎么写的
A sender MUST NOT send a Content-Length header field in any message
that contains a Transfer-Encoding header field.
3. If a message is received with both a Transfer-Encoding and a
Content-Length header field, the Transfer-Encoding overrides the
Content-Length. Such a message might indicate an attempt to
perform request smuggling (Section 11.2) or response splitting
(Section 11.1) and ought to be handled as an error. An
intermediary that chooses to forward the message MUST first
remove the received Content-Length field and process the
Transfer-Encoding (as described below) prior to forwarding the
message downstream.
第一段是发送方的义务:报文里带了 Transfer-Encoding,就不能再带 Content-Length。第二段是接收方的处理顺序:两个都收到时,Transfer-Encoding 说了算,而且这种报文「ought to be handled as an error」。规范还顺手给了理由,它可能是请求走私的试探。中间件如果要继续转发,必须先把这个 Content-Length 删掉。
第三段管的是另一种写法:报文没有 Transfer-Encoding,Content-Length 又不合法时,接收方要么当不可恢复的错误,要么把它的值当逗号分隔的列表逐项检查。
九条报文,三条进到处理函数
== 3. 两个头同时出现(Content-Length 在前)==
报文
状态行: HTTP/1.1 400 Bad Request
正文: (空)
== 4. 两个头同时出现(Transfer-Encoding 在前)==
报文
状态行: HTTP/1.1 400 Bad Request
正文: (空)
== 5. 两个 Content-Length 且值不同 ==
报文
状态行: HTTP/1.1 400 Bad Request
正文: (空)
== 6. 两个 Content-Length,值相同 ==
报文
状态行: HTTP/1.1 400 Bad Request
正文: (空)
== 7. Content-Length 声明 100,实际只发 5 个字节 ==
报文
状态行: (没有响应)
正文: (空)
== 8. Content-Length 是负数 ==
报文
状态行: HTTP/1.1 400 Bad Request
正文: (空)
== 9. Transfer-Encoding: gzip, chunked(未知编码在前)==
报文
状态行: HTTP/1.1 200 OK
正文: 8
ok:hello
0
== 10. 服务端实际收到并交给处理函数的请求 ==
{"headers":{"content-length":"5"},"body":"hello","httpVersion":"1.1"}
{"headers":{"transfer-encoding":"chunked"},"body":"hello","httpVersion":"1.1"}
{"clientError":"HPE_INVALID_TRANSFER_ENCODING","rawPacket":"POST /c HTTP/1.1"}
{"clientError":"HPE_INVALID_CONTENT_LENGTH","rawPacket":"POST /d HTTP/1.1"}
{"clientError":"HPE_UNEXPECTED_CONTENT_LENGTH","rawPacket":"POST /e HTTP/1.1"}
{"clientError":"HPE_UNEXPECTED_CONTENT_LENGTH","rawPacket":"POST /f HTTP/1.1"}
{"clientError":"HPE_INVALID_EOF_STATE","rawPacket":""}
{"clientError":"HPE_INVALID_CONTENT_LENGTH","rawPacket":"POST /h HTTP/1.1"}
{"headers":{"transfer-encoding":"gzip, chunked"},"body":"hello","httpVersion":"1.1"}
== 11. 小结数字 ==
发出去的报文: 9 条
进到处理函数的: 3 条
被 clientError 拦下的: 6 条
客户端用的是裸 socket,手工拼出九条报文,绕开高层客户端对头部的整理。九条里进到处理函数的只有三条:只带 Content-Length 且长度对得上的、只带 chunked 的,以及 Transfer-Encoding 里 chunked 排在最后但前面还挂了一个编码的那条。另外六条都在解析阶段被拦下,业务代码一行没跑到。
拒绝的方式分几类。两个头同时出现,无论哪个在前,都是 400 Bad Request,错误码分别是 HPE_INVALID_TRANSFER_ENCODING 和 HPE_INVALID_CONTENT_LENGTH。两个 Content-Length 出现,值相同值不同都回 400,错误码 HPE_UNEXPECTED_CONTENT_LENGTH。Content-Length 写成负数也是 400。
值相同的两个 Content-Length 也被拒了
规范那段原文留了一个口子:如果逗号分隔的列表里每个值都合法且完全相同,接收方不一定要当错误。Node 在实测里没走这个口子,两个值都是 5 的 Content-Length 一样回 400。
这一条值得记下来,因为它是链路上最容易出现分歧的地方。反向代理和后端服务器对这个报文的判断只要有一处不同,同一串字节在不同组件眼里就是两个不同的请求,长度不同、边界不同,后面的处理全跟着错。
声明 100 字节只发 5 个,连接就挂在那里
有一种写法拿不到状态行。报文声明 Content-Length 是 100,实际只写了 5 个字节就停住,客户端等不到任何响应,服务端记下的错误码是 HPE_INVALID_EOF_STATE。它说明解析层在等剩下的 95 个字节,连接断开时才判定报文不完整。
这类请求在正常客户端上很难复现,因为成熟的 HTTP 库会自己算长度。手写报文、或者自己拼字符串拼出 Content-Length 的地方,才有可能发出这种报文。
链路上盯这三处
第一处是转发环节:代理把请求转给后端时,长度声明要统一,转发了 Transfer-Encoding 就把 Content-Length 去掉。规范原文那句「MUST first remove」说的就是这个动作。
第二处是客户端:别手写 Content-Length,交给 HTTP 库算。库会按实际发送的字节数填,手写的那个数一旦和字节数对不上,服务端就要挂在那里等。
第三处是双端一致性:代理与后端对同一个报文的接受范围要一样。一边按规范放行、一边直接拒绝,同一串字节就会被解读成两个不同的请求。
复现这件事只要十分钟:起一个 node 的 http 服务,用裸 socket 把两个头一起发过去,看状态行是 200 还是 400。我倾向于让解析层的默认行为管这件事,不在业务里加兼容处理,因为放行这种报文的风险,比多回一个 400 大得多。