第 2 章 报文:如何用一个纯文本协议承载整个世界
协议里最不起眼的问题,往往是最致命的:接收方怎么知道,这个报文到哪儿算完?
2.0 引子:一个偶发的”半个请求”
老张:老哥,出事了。线上偶发 unexpected EOF,业务日志里说”只处理了半个请求”,但请求日志里这条请求是 200。
你:半个请求?不可能啊,HTTP 要么收到要么收不到……
老张:你自己看监控。凌晨高峰期,大概每几万条就有一条,nginx 还报 upstream prematurely closed connection。
你:连接被提前关闭了?谁关的?
墨叔:停。这个问题,你的直觉是往”谁关了连接”这个方向查。但真正的钥匙,藏在另一个更基础的问题里——
服务器到底凭什么知道”一个请求已经收完了,可以开始处理了”?
你:凭连接断开?
墨叔:那你告诉我,如果客户端发到一半,网线被拔了,服务器怎么区分”这是完整的请求”和”这是一个被截断的请求”?
你:……(沉默)
墨叔:答不上来是吧。这就是今天要讲的东西。它看起来像个无聊的解析细节,但它其实决定了 HTTP 这三十年里至少一半的架构决策,甚至决定了一个安全漏洞的类型。
2.1 先把问题问对:什么是”边界”
墨叔:任何通信协议,本质都是”在一条字节流上传递一个个消息”。TCP 是一条流,它保证字节的顺序和不丢,但它不保证消息的边界。
我给你发 100 个字节,这 100 个字节可能是:
- 一条 100 字节的消息
- 十条 10 字节的消息
- 一条 30 字节 + 一条 70 字节的消息
TCP 分不清,它也不想分清。 分清这件事,是你的责任——是 HTTP 的责任。
你:所以”边界”就是——怎么从一条字节流里,准确地切出”一个完整的报文”。
墨叔:对。现在我问你,如果你是 1991 年的设计者,你能想到几种切法?
你:嗯……三种吧。第一,用特殊字符分隔,比如换行;第二,约定一个长度,先告诉对方”我要发多少字节”;第三,发完就关连接。
墨叔:不错,你说的这三种,正是 HTTP 历史上用过的全部三种,而且它们现在还并存。这就是最精彩的地方——HTTP 没有选一种,它把三种都保留了,在不同的场景用不同的。
我们一个个看,尤其是它们各自的死穴。
2.2 方案一:关闭连接作为结束信号
墨叔:最早的 HTTP/0.9,只有一种请求:GET /path,没有请求头,没有版本号。服务器响应一个 HTML,发完直接关闭连接。
这个设计简单到极致:“连接断了 = 报文完了”。
你:听起来挺聪明啊,省了所有解析。
墨叔:它有个致命的死穴,你猜是什么?
你:……发完就断,那下一个请求还得重新握手?慢。
墨叔:这是性能问题,但不是死穴。真正的死穴是——
“连接断开”和”数据发完”是两件事,你永远无法区分它们。
我发给你一个响应,发送到一半,服务器进程崩了,连接断了。你收到的半个响应,你怎么知道它是完整的还是残缺的?
你:……没法知道。
墨叔:对。所以”关闭连接作为结束信号”这个方案,在”一切正常”时工作得完美,在”出了任何差错”时彻底失效。
这恰好回到了第 1 章说的:协议最难的不是约定成功长什么样,而是处理异常。这个方案在异常面前裸奔。
所以它只能用在”内容极端可靠”的早期场景,很快就被淘汰了。但它的思路没死——你现在还在用:比如很多”流式下载”场景,服务器干脆用 Connection: close 告诉客户端”我用关连接来表示结束”。
2.3 方案二:Content-Length,先报数再交货
墨叔:为了解决”区分正常结束和异常断开”,HTTP/1.0 引入了 Content-Length。
请求里写清楚:
GET /report HTTP/1.0
响应里写清楚:
HTTP/1.0 200 OK
Content-Type: text/html
Content-Length: 42
<!DOCTYPE html>...(正好 42 个字节)
接收方数着字节收,收满 42 个,就知道完了。 多一个字节,说明协议坏了;少一个字节,说明传输断了。
你:这个稳多了。那还要方案三干嘛?
墨叔:因为 Content-Length 有两个前置条件,缺一个就玩不转。
前置条件一:发送方必须在发送之前,就知道内容有多长。
这听起来天经地义,但实际上——很多内容你生成之前是不知道长度的。
想象你要把一个大查询的结果流式地发给客户端:数据库边查边返回,你边收边写。这时候你不知道最终会有多少字节。难道要先全部攒在内存里数一遍,再发?那 200MB 的报表,内存就炸了。
前置条件二:长度必须是精确的,一个字节都不能错。
第 1 章的案例 C 你还记得吗?前端报了 Content-Length: 10485760,结果只发了 5MB 就断了。服务器如果信了这个长度,就会傻等剩下 5MB,直到超时。反过来,如果实际发多了,多出来的字节会被当成下一条消息——这就出大事了。
你:我懂,所以 Content-Length 本质是一个承诺。
墨叔:对,一个必须精确兑现的承诺。一旦承诺和实际不符,就是协议级的错误,必须中断。所有严格遵守的框架,遇到 Content-Length 对不上,都会报错——unexpected EOF。
现在回头看引子里老张的 bug:upstream prematurely closed connection + unexpected EOF,大概率就是:客户端声明了一个长度,实际没发够,连接就断了。服务器等不到承诺的字节,报 EOF。
你:哦!所以这不是”谁关了连接”的问题,是”长度承诺没兑现”的问题。
墨叔:进步了。记住:排查报文问题,先看长度承诺兑现了没有,再去看是谁断的连接。
2.4 方案三:chunked,边发边分段
墨叔:现在解决”发之前不知道长度”的问题。HTTP/1.1 引入了 Transfer-Encoding: chunked。
思路是:不预先报总长度,而是把 body 切成一段一段,每段先报”这一段多长”,再发这一段。
HTTP/1.1 200 OK
Transfer-Encoding: chunked
1a\r\n <- 十六进制的 26,表示这一段 26 字节
这是一段 26 个字节的内容...\r\n
1b\r\n <- 再一段,27 字节
又一段 27 个字节的内容...\r\n
0\r\n <- 长度为 0 的段,表示"结束"
\r\n
你:最后那个 0\r\n\r\n 就是结束标志?
墨叔:对。0 这一段叫结束块(terminating chunk)。它的存在让”结束”这件事不再依赖连接断开——即使连接还活着,接收方也知道收完了。
你:那 chunked 有什么代价?
墨叔:代价就是每一段都要多带一个长度头,多了一层解析。而且——注意一个细节——Transfer-Encoding: chunked 是**逐跳(hop-by-hop)**的,第 5 章会讲这个要命的概念。
但最关键的代价是:chunked 和 Content-Length 不能同时用。一个报文只能选一种边界方式。如果两个都出现,就引发了 2.6 节要讲的那个著名漏洞。
你:等等,Go 里我从来没手动写过 chunked,它是什么时候被用的?
墨叔:好问题。看 Go 代码:
func handler(w http.ResponseWriter, r *http.Request) {
// 情况 1:一次写完,内容小 → net/http 先攒着,写完后自动补 Content-Length
w.Write([]byte("hello")) // 响应头出现 Content-Length: 5
// 情况 2:内容大,超过缓冲,或你显式 Flush → 自动切到 chunked
for i := 0; i < 10000; i++ {
w.Write(bigChunk)
w.(http.Flusher).Flush() // 此刻起,Transfer-Encoding: chunked
}
}这就是第 1 章说的”框架替你做的决定”:net/http 判断”要不要 chunked”的逻辑是——如果它能在写完第一个字节前就知道总长度(内容小、已经全部在内存里),就用 Content-Length;一旦你开始 Flush 或者内容超过了它的缓冲阈值,它就不得不切换到 chunked,因为”已经开写了,没法回头补长度了”。
你:原来 chunked 是”写出去就收不回来”时的兜底方案。
墨叔:精确。Content-Length 是”计划好了再出发”,chunked 是”边走边报”。前者省事,后者灵活。协议两者都给你,你自己选。
2.5 方案四:multipart,给 body 里再嵌一套边界
墨叔:还有一种更隐蔽的边界问题,藏在 body 内部。
上传文件时,你一个请求要带:一段表单字段 + 一个文件。这两块内容拼在一个 body 里,接收方怎么知道”表单到哪结束,文件从哪开始”?
你:再用长度?但文件多大、字段多长,前端也得先算……
墨叔:HTTP 用了第四种方案:分隔符。前端生成一个随机字符串作为边界,声明在 Content-Type 里:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="a.xlsx"
Content-Type: application/octet-stream
<二进制文件内容>
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="desc"
这是一个描述
------WebKitFormBoundary7MA4YWxkTrZu0gW--
你:我看到了——每个字段前面有个 --boundary,最后是 --boundary--(两个连字符表示结束)。
墨叔:对。这个设计的精妙在于:boundary 是随机的,所以它几乎不可能出现在文件内容里(概率极小)。接收方扫描这个字符串就能切块。
但它也有个隐忧,你想想是什么?
你:如果文件内容里恰好包含了这个 boundary 字符串呢?
墨叔:对。所以标准要求发送方在选 boundary 时要保证它不出现在内容里(现实中靠随机性和长度来保证概率上不可能)。这背后是一个通用规律:
用分隔符做边界,前提是”分隔符不出现在内容里”。所有转义机制、所有 base64、所有带引号的 CSV,都是为了守住这个前提。
2.6 终极体现:请求走私(Request Smuggling)
墨叔:现在,把方案二和方案三放在一起,会出现一个经典的安全灾难。
关键前提:HTTP 报文经常要经过代理/网关转发。代理是一台机器,源站是另一台机器,它们各自独立解析报文。
现在,攻击者构造一个报文,故意同时带上 Content-Length 和 Transfer-Encoding:
POST / HTTP/1.1
Host: example.com
Content-Length: 13
Transfer-Encoding: chunked
0\r\n
\r\n
SMUGGLED...
你:这算什么?两个边界声明,矛盾了。
墨叔:矛盾不可怕,可怕的是两台机器对矛盾的处理不一致:
- 代理(前端的机器)看到
Transfer-Encoding: chunked,按 chunked 解析:读到0就认为请求结束了。于是它认为这个请求只有 chunked 部分,剩下的SMUGGLED...是下一个请求的开头,先存着。 - 源站(后端的机器)看到
Content-Length: 13,按长度解析:读 13 个字节,结果它把SMUGGLED这几个字节也算进了上一个请求的 body 里。
于是,代理以为发了一个请求,源站以为收到了两个。那串 SMUGGLED 就”走私”进了下一个请求的位置。
攻击者利用这一点,能干什么?
- 响应错配:让用户 A 收到用户 B 的响应(缓存投毒)。
- 绕过安全过滤:WAF 看到的是一段,源站执行的是另一段。
- 劫持请求:把别人请求里的 Cookie、token 截到自己构造的请求里。
你:……这么细的地方也能被利用?
墨叔:协议安全从来不在”大面上”,都在这些两个实现之间的缝隙里。请求走私之所以危险,正是因为:
分层系统里,每一层都在独立地做同一个解析。只要两层对同一个字节流的解读不同,中间就一定有漏洞。
这跟第 1 章说的”分层约束”是一体两面:分层给了你 CDN、代理、网关,也给了你”两套解析之间”的攻击面。
防护:
- 前端代理和后端源站对歧义的处理规则要一致(用同一套 HTTP 栈,或统一配置)。
- 现在的标准做法是:同时出现 CL 和 TE 时,一律拒绝或关闭连接。
- 统一 HTTP/2(它没有这种歧义,帧格式更严格)。
2.7 为什么是纯文本,而不是二进制?
墨叔:绕了一大圈,回到一个你第一眼可能没想过的问题——为什么 HTTP 报文是纯文本的?
你天天在跟 JSON 打交道,觉得文本天经地义。但站在协议设计角度,文本其实是个很反常的选择。二进制更紧凑、解析更快、没有大小写歧义、没有字符集问题。
你:那当年为什么选文本?
墨叔:三个理由,每个都是那个年代的决定性因素:
理由一:可调试性,这是最关键的。 文本意味着——你可以用 telnet 手写一个 HTTP 请求:
$ telnet example.com 80
GET / HTTP/1.1
Host: example.com
没有编译器,没有 SDK,一个大学生拿着 telnet 就能跟服务器对话。这个”能亲手敲出来”的能力,让 HTTP 的采用成本降到了零。 第 1 章说过”简单决定采用率”,文本就是”简单”的最直观体现。
对比一下当年的对手 Gopher:它的报文更简单,但协议设计得”不完整”,扩展性差,最终被 HTTP 干掉。
理由二:跨平台、跨语言零成本。 文本没有字节序问题(big-endian vs little-endian)、没有结构体对齐问题、没有 32 位/64 位长度问题。任何语言读写字符串都是基础能力。二进制协议的第一个坑永远是”字节序和对齐”,文本直接绕过了它。
理由三:HTTP 是”给人设计的中转站”,而不是”给机器设计的管道”。 别忘了 HTTP 设计的年代,中间会有代理、网关、缓存。这些中间设备需要读懂报文才能干活(缓存要看 URI,网关要看 Host)。文本让中间设备开发者也轻松。
代价(你也要知道):
| 代价 | 具体表现 |
|---|---|
| 解析慢 | 要扫描字符、处理大小写、跳过空格,比二进制慢一个量级 |
| 有歧义 | 大小写、连续空格、行尾是 \r\n 还是 \n,都得定义清楚 |
| 有安全隐患 | 字符串拼接、边界不清 → 请求走私、头注入 |
| 体积大 | 同样的数据,文本表达比二进制长 |
这些代价,最终在 HTTP/2 里被算总账了——HTTP/2 把报文改成了二进制帧(第 12 章)。但注意:HTTP/2 改的是”线上格式”,没改”语义”。方法还是 GET,首部还是 Content-Type,只是它们被压缩成了二进制。这说明了一个重要道理:
协议的语义和语法是可以解耦的。语义可以几十年不变,语法可以彻底换掉。
2.8 设计者视角:如果让你重新定报文的骨架
墨叔:现在把角色切回设计者。给你三个必须回答的问题:
问题一:为什么请求行的顺序是 方法 路径 版本,而不是别的?
你:……方法放最前面,是为了路由快?
墨叔:对,但更深一层。想想中间设备——代理、网关、WAF——它们要决定”这个请求该转发给谁、该怎么处理”,只需要看报文最前面的几个字节就够了。方法在前,意味着一个路由器甚至不用读完整行,扫到第一个空格就知道这是 GET 还是 POST。
而版本号放在最后,也是刻意的:因为”方法”和”路径”是每个请求都不同的,版本号是千篇一律的。把最变化的东西放前面,把最稳定的放后面,是给未来的扩展留空间——万一以后要加字段,加在后面不影响老解析器。
问题二:为什么首部和 body 之间用”一个空行”分隔,而不是用一个长度字段?
你:这个我也想问。用长度不是更精确吗?
墨叔:用长度更精确,但更脆。你想想:如果你要先声明”首部有 N 字节”,那解析器就得先数首部、再解析首部——两遍。而用空行,解析器可以边读边解析,读到空行就停。
更深的原因是容错性:首部的长度经常因为中间设备的改写而变化(代理会加头、改头)。如果用长度,任何改动都可能导致长度失配。空行分隔是”自描述”的——边界跟着内容走,不跟着计数走。
这跟 2.4 节 chunked 的设计是同一个哲学:能用”自描述的标记”解决边界,就不用”预先的计数”。
问题三:CRLF(\r\n)为什么不是 LF(\n)?
你:这个……历史原因?打字机?
墨叔:对,就是历史。早期电传打字机(teletype),\r(回车)把打印头拉回行首,\n(换行)把纸往下滚一行。两者配合才是一行。这个习惯被带进了 ARPANET 时代,写进了各种早期协议。
但现实是:大量的实现偷懒,只发 \n,不发 \r。于是 HTTP 标准自己也不得不妥协——RFC 明确规定:接收方必须容忍裸 \n 作为行结束。这就是第 5 题会聊的”宽容”在报文层的体现。
2.9 Go 落地:亲手看一眼原始报文
墨叔:我们不用 net/http,直接用 TCP 手写一个请求,看看原始字节长什么样:
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
conn, _ := net.Dial("tcp", "example.com:80")
defer conn.Close()
// 手写一个最小请求:注意每一行结尾是 \r\n,最后要有一个空行
fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
// 逐行读出原始响应头
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n') // 读到 \n 为止
fmt.Printf("%q\n", line)
if err != nil || line == "\r\n" { // 空行 = 首部结束
break
}
}
}这段代码会打印出:
"HTTP/1.1 200 OK\r\n"
"Age: 12345\r\n"
"Cache-Control: max-age=604800\r\n"
"Content-Type: text/html; charset=UTF-8\r\n"
"Content-Length: 1256\r\n"
"\r\n" <- 这个空行,就是首部和 body 的分界
你:我第一次这么清楚地”看见”空行这个边界。
墨叔:这就是”看见协议”的价值。你现在看着这段 \r\n\r\n,和三个月前看着它,脑子里装的东西已经完全不一样了。
2.10 排查清单:报文层问题定位顺序
墨叔:遇到”请求只处理了一半""unexpected EOF""502”这类问题,按这个顺序查:
第 1 步:抓包看原始报文(curl -v 或 tcpdump)
→ Content-Length 声明了多少?实际发够了没?
→ 有没有 CL 和 TE 同时出现?
第 2 步:中间有没有代理/网关在改写报文?
→ 有的代理会加头、改长度、甚至把请求合并
第 3 步:是不是 chunked 的边界块丢失?
→ 检查是不是有中间设备截断了 0\r\n\r\n
第 4 步:multipart 的 boundary 是否前后一致?
→ 前端 multipart 编码 bug 是"半个请求"的高频来源
第 5 步:确认"半包"到底发生在哪一跳
→ 客户端发 → 代理 → 源站,逐跳抓包对比长度
思考题
第 1 题(概念)
Content-Length 和 Transfer-Encoding: chunked 都能让接收方找到报文边界。请说出:
- 它们各自的适用场景是什么?
- 为什么不能同时使用?
- 如果一个 Go 服务同时设置了这两个(比如手动写了 Content-Length 又 Flush 了),
net/http会怎么处理?
第 2 题(概念)
HEAD 请求的响应没有 body,但标准规定它可以带 Content-Length。这个 Content-Length 表示什么?为什么一个”没有 body”的响应还要告诉你长度?
第 3 题(设计)
你要设计一个”流式接口”:服务器要把一个巨大的日志文件,边读取边实时推给客户端,客户端要能边收边处理。
- 你会用 Content-Length 还是 chunked?为什么?
- 如果传输中途服务器崩溃了,客户端怎么才能发现”这个流是不完整地结束的”,而不是”正常地结束了”?(提示:chunked 的结束块在这里起了什么作用?)
第 4 题(生活类比)
墨叔:寄快递时,包裹上会写”共 N 件,第 k 件”;写信时,一封信就是一张纸,读完一张就到头了;发微信语音时,你对着手机说完就松手。
这三种日常场景,分别对应 HTTP 的哪种边界方案?并指出:快递的”共 N 件第 k 件”方案,如果遇到”少了一件”会怎样,这对应了协议里的什么问题?
第 5 题(更深一层的发问)
请求走私(2.6 节)的根因是”代理和源站对同一段字节流的解析不一致”。那么:
- 如果所有机器都用同一套、严格实现的 HTTP 栈,请求走私就消失了吗?
- 现实中为什么做不到”所有机器都用同一套栈”?(提示:从”分层约束”和”设备生命周期”两个角度想)
- 这个答案,跟第 1 章说的”一个协议的成功取决于它允许多少人犯错误还活下来”,有没有矛盾?
参考答案
第 1 题答案
1. 适用场景:
| 方案 | 前提 | 场景 |
|---|---|---|
| Content-Length | 发送前就知道总长度 | 静态文件、一次生成的完整响应、小而确定的 body |
| chunked | 发送前不知道,或想边生成边发 | 流式查询、大文件边读边发、SSE、实时推送 |
2. 为什么不能同时用: 因为边界只能有一个。如果两个都声明,接收方就有了两套互相矛盾的”结束信号”,必然出现歧义——这正是请求走私(2.6 节)的温床。所以标准明确:同一报文里 CL 和 TE 互斥。规范(RFC 9112)甚至建议:如果同时收到,优先相信 TE 并忽略 CL,或者干脆拒绝。
3. Go 里会发生什么:
net/http 的规则是:如果你手动设置了 Content-Length 头,它就用 Content-Length;否则才考虑 chunked。 具体地:
- 如果你
w.Header().Set("Content-Length", "10"),然后写 body,net/http 会尊重你的声明,写满 10 字节就完事(写多了它会截断,写少了连接挂起等)。 - 如果你没设 Content-Length,写完第一个字节时它还没办法知道总长,就会自动用 chunked。
- 关键是:net/http 内部会用”是否已经设置了 CL”来决定。一旦你设了 CL 又调 Flush,它会报错(
http: wrote more than the declared Content-Length或日志告警),因为它不能既承诺长度又分块。这就是框架在替你守住”CL 和 chunked 互斥”这条协议契约。
第 2 题答案
HEAD 响应没有 body,但它的 Content-Length 表示的是:如果这是一个 GET 请求,对应的 body 会有多长。
为什么要这样?因为 HEAD 的语义是”只拿头、不拿体”。调用方(比如一个下载器、一个爬虫、一个前端)经常用它来预先探测资源的大小——“这个文件多大?我该开多大缓冲区?该不该显示进度条?” 如果没有 Content-Length,调用方就只能盲下。
更深一层:HEAD 的语义决定了,它的响应头应该和对应的 GET 响应头完全一致(除了没有 body)。这保证了”探测”和”实际获取”看到的是同一个世界。这也是为什么规范强调 HEAD 的响应头要和 GET 一致——如果 HEAD 告诉你 1MB,GET 却返回 2MB,你就被坑了。
(这个”头要一致”的约定,和第 8 章缓存的校验、第 10 章内容协商都有联动:它们都依赖”元数据如实描述实体”。)
第 3 题答案
1. 用 chunked。 因为发送前不知道总长度(流式读取),这正是 chunked 的设计场景。
2. 靠”结束块”来区分正常结束和异常中断。
这是这道题的核心。chunked 的设计里,正常结束的标志是显式的 0\r\n\r\n 结束块。也就是说:
- 客户端收到了
0\r\n\r\n→ 这是服务器主动说的”我发完了”,是正常结束。 - 客户端没有收到
0\r\n\r\n,连接就断了 → 这是异常中断。 客户端就知道”这个流是残缺的”,可以报错、可以重试。
如果反过来,用”关闭连接”作为结束信号(方案一),你就永远分不清这两种情况——这正是方案一的死穴。所以这道题的深层答案是:
“用显式的结束标记,而不是用连接的物理断开来表示结束”——这是 chunked 相对早期方案最本质的进步。它把”结束”从一个物理事件(连接断了)升级成了一个协议事件(收到结束块)。
物理事件是模糊的、不可控的;协议事件是明确的、可检测的。好的协议,永远倾向用”协议内的事件”而不是”物理层的事件”来表达语义。
第 4 题答案
| 日常场景 | 边界方式 | 对应 HTTP |
|---|---|---|
| 寄快递”共 N 件第 k 件” | 预先声明数量 | Content-Length |
| 写信(一封信一张纸) | 物理结束即语义结束 | 关闭连接(方案一) |
| 微信语音(说完松手) | 显式的结束动作 | chunked 的结束块 / multipart 的结束分隔符 |
关于快递”少一件”:
如果包裹上写”共 3 件”,你收到了 2 件,快递员说没了。这时候你会:
- 知道东西没到齐(因为有”共 3 件”这个承诺)
- 不知道那第 3 件是”丢了”还是”还在路上”还是”被快递员吞了”
这对应的协议问题正是:
“长度承诺没兑现”时,接收方只能确定”残缺”,却无法确定”残缺的原因”——是发送方少发了?还是网络丢了?还是中间人截了?
所以协议能做的,只是把”承诺”和”兑现”对齐,一旦对不齐就报错(unexpected EOF)。至于到底是谁的锅,协议本身查不出来,得靠上层(日志、抓包、对账)去定位。这再次印证:协议无法帮你确认真实世界发生了什么,它只能把”不一致”响亮地暴露出来。
第 5 题答案
1. 不会消失,只是攻击面变小。
严格来说,如果所有实现都严格且一致,CL/TE 的歧义会消失。但请求走私的本质是”两个独立解析器对同一输入的解读不同”,这个”两个解析器”的场景是结构性存在的:
- 即使都是同一套栈,版本可能不同(一台是 2020 年的版本,一台是 2023 年的,中间有个修复了、有个没修复)。
- 即使版本相同,配置可能不同(一台开启了”容忍模式”,一台是严格模式)。
所以”都用同一套栈”能大幅降低风险,但不能从根上消灭。真正的根除要靠协议设计:消除歧义本身——这就是 HTTP/2 做的(帧格式严格,不存在 CL/TE 二义性)。
2. 为什么做不到”全世界统一栈”:
- 分层约束:HTTP 的价值恰恰在于中间可以有各种设备。这些设备(代理、WAF、CDN、负载均衡、API 网关、还有你公司里那个老旧的 Nginx)由不同的厂商、不同的时代、不同的人实现。你无法强制它们升级。
- 设备生命周期:一台 2010 年出厂、跑在机房角落里、从没升级过的反向代理,可能至今还在生产环境里转发流量。它的 HTTP 解析器是 2010 年的认知。“管不着”和”动不了”是两回事,而 HTTP 世界里两者都存在。
所以答案是:现实中你永远面对”多个实现”并存,所以歧义永远有土壤。 这正是第 1 章”分层”约束的代价。
3. 不矛盾,反而是同一个道理的两面:
“协议的成功取决于它允许多少人犯错还活下来”——这里的”错”包括实现不一致的错。HTTP 恰恰是靠”容忍这些不一致”(比如容忍裸 LF、忽略未知头)才活下来的。
但请求走私告诉你:容忍是有边界的。在”边界判定”这种语义攸关的地方,容忍会导致灾难。所以 HTTP 的策略是:
- 在无害的、语义不敏感的地方宽容(多一个空格、未知头、裸 LF)—— 这是为了活下去。
- 在语义攸关的地方严格(CL 和 TE 冲突、长度不符必须报错)—— 这是为了不被打死。
一句话总结这道题:宽容是一种资源,只能用在”错了也不会死人”的地方;边界判定错了一定死人,所以这里必须严格。 这和第 1 章第 5 题的答案完全咬合上了。
一页纸小结
边界,是 HTTP 最不起眼却最致命的问题:接收方怎么从一条 TCP 字节流里,切出”一个完整的报文”。
四种边界方案:
- 关闭连接——简单,但无法区分”正常结束”和”异常断开”,已淘汰。
- Content-Length——预先报长度,稳,但要求”发送前知道长度”,且是”必须精确兑现的承诺”。
- chunked——边发边报段长,灵活,适合流式,用
0\r\n\r\n作显式结束标记。 - multipart——body 内嵌随机分隔符,用于文件+字段混合上传。
核心哲学:能用”自描述的标记”解决边界,就不用”预先的计数”;能用”协议内事件”表达结束,就不用”物理层事件”。
请求走私:CL 与 TE 冲突时,代理和源站解析不一致 → 攻击者走私请求。根因是”分层系统中多个解析器并存”。
为什么是纯文本:可调试(telnet 手写)、跨平台零成本、中间设备要读懂。代价是慢、有歧义、有隐患——最终 HTTP/2 换了语法但没换语义。
Go 落地:net/http 自动在 Content-Length 和 chunked 之间切换,切换依据是”写之前是否知道总长度”。
排查:报文问题先看”长度承诺兑现了没有”,再看”是谁断的连接”。
下一章:报文会读会写了,但”读什么、写什么”还没说。我要问你一个能让你重新审视所有接口的问题——为什么同样一个操作,用 GET 和用 POST 会导致完全不同的世界? 以及那个让无数人半夜惊醒的问题:你的接口,经得起被自动重试一次吗?