第 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 就”走私”进了下一个请求的位置。

攻击者利用这一点,能干什么?

  1. 响应错配:让用户 A 收到用户 B 的响应(缓存投毒)。
  2. 绕过安全过滤:WAF 看到的是一段,源站执行的是另一段。
  3. 劫持请求:把别人请求里的 Cookie、token 截到自己构造的请求里。

你:……这么细的地方也能被利用?

墨叔:协议安全从来不在”大面上”,都在这些两个实现之间的缝隙里。请求走私之所以危险,正是因为:

分层系统里,每一层都在独立地做同一个解析。只要两层对同一个字节流的解读不同,中间就一定有漏洞。

这跟第 1 章说的”分层约束”是一体两面:分层给了你 CDN、代理、网关,也给了你”两套解析之间”的攻击面。

防护:

  1. 前端代理和后端源站对歧义的处理规则要一致(用同一套 HTTP 栈,或统一配置)。
  2. 现在的标准做法是:同时出现 CL 和 TE 时,一律拒绝或关闭连接。
  3. 统一 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 都能让接收方找到报文边界。请说出:

  1. 它们各自的适用场景是什么?
  2. 为什么不能同时使用?
  3. 如果一个 Go 服务同时设置了这两个(比如手动写了 Content-Length 又 Flush 了),net/http 会怎么处理?

第 2 题(概念)

HEAD 请求的响应没有 body,但标准规定它可以带 Content-Length。这个 Content-Length 表示什么?为什么一个”没有 body”的响应还要告诉你长度?

第 3 题(设计)

你要设计一个”流式接口”:服务器要把一个巨大的日志文件,边读取边实时推给客户端,客户端要能边收边处理。

  1. 你会用 Content-Length 还是 chunked?为什么?
  2. 如果传输中途服务器崩溃了,客户端怎么才能发现”这个流是不完整地结束的”,而不是”正常地结束了”?(提示:chunked 的结束块在这里起了什么作用?)

第 4 题(生活类比)

墨叔:寄快递时,包裹上会写”共 N 件,第 k 件”;写信时,一封信就是一张纸,读完一张就到头了;发微信语音时,你对着手机说完就松手。

这三种日常场景,分别对应 HTTP 的哪种边界方案?并指出:快递的”共 N 件第 k 件”方案,如果遇到”少了一件”会怎样,这对应了协议里的什么问题?

第 5 题(更深一层的发问)

请求走私(2.6 节)的根因是”代理和源站对同一段字节流的解析不一致”。那么:

  1. 如果所有机器都用同一套、严格实现的 HTTP 栈,请求走私就消失了吗?
  2. 现实中为什么做不到”所有机器都用同一套栈”?(提示:从”分层约束”和”设备生命周期”两个角度想)
  3. 这个答案,跟第 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 字节流里,切出”一个完整的报文”。

四种边界方案:

  1. 关闭连接——简单,但无法区分”正常结束”和”异常断开”,已淘汰。
  2. Content-Length——预先报长度,稳,但要求”发送前知道长度”,且是”必须精确兑现的承诺”。
  3. chunked——边发边报段长,灵活,适合流式,用 0\r\n\r\n 作显式结束标记。
  4. multipart——body 内嵌随机分隔符,用于文件+字段混合上传。

核心哲学:能用”自描述的标记”解决边界,就不用”预先的计数”;能用”协议内事件”表达结束,就不用”物理层事件”。

请求走私:CL 与 TE 冲突时,代理和源站解析不一致 → 攻击者走私请求。根因是”分层系统中多个解析器并存”。

为什么是纯文本:可调试(telnet 手写)、跨平台零成本、中间设备要读懂。代价是慢、有歧义、有隐患——最终 HTTP/2 换了语法但没换语义。

Go 落地:net/http 自动在 Content-Length 和 chunked 之间切换,切换依据是”写之前是否知道总长度”。

排查:报文问题先看”长度承诺兑现了没有”,再看”是谁断的连接”。


下一章:报文会读会写了,但”读什么、写什么”还没说。我要问你一个能让你重新审视所有接口的问题——为什么同样一个操作,用 GET 和用 POST 会导致完全不同的世界? 以及那个让无数人半夜惊醒的问题:你的接口,经得起被自动重试一次吗?