📘 第六章 HTTP首部
📄 一句话概括

Content-Length 说正文有多少字节,必须与实际一字不差。 写小了会被当成报文截断,写大了会让接收方一直等,写错位就是请求走私的入口。

📝 本节和前面几节的关系

前面几个字段都是「描述」,这个字段是「边界」。 整个 HTTP 报文能不能被正确切开,全靠它和分块传输两个机制。

一、四种写法,三种出错

Demo 里正文固定 50 字节,声明值分别写成 50、0、20、120,客户端老老实实按声明值读: | 声明 | 实发 | 客户端收到 | 结果 | |---:|---:|---:|---| | 50 | 50 | 50 | 正常 | | 0 | 50 | 0 | 正文被当成 0 字节 | | 20 | 50 | 20 | 只收前 20 字节 | | 120 | 50 | 50 | 等不全,超时收场 | 只有第一行是对的。第二行声明 0 却发了 50 字节——客户端一个字节都不读,那 50 字节就留在连接里了。 在持久连接上这比「丢数据」更糟:剩下的 50 字节会被当成下一个响应的开头, 于是整条连接上后续所有报文全部错位。这就是请求走私最土的一种做法。

二、和分块传输的关系

HTTP/1.1 里有两个机制能定出正文边界:Content-Length 给绝对长度, Transfer-Encoding: chunked 给一组带长度的块。 两个同时出现时,规范要求接收方以 Transfer-Encoding 为准,并忽略 Content-Length。 D064 实测过:故意把 Content-Length 写成两倍,接收方照旧按块读完 840 字节。 这条规定本身就是安全防线。 历史上很多走私漏洞,就出在「前端认 CL,后端认 TE, 两边对同一个报文的边界理解不同」——中间夹进去的那段字节,就成了攻击者的通道。

三、哪些情况不能有它

用了分块传输就不能发;204 和 304 这类明确没有正文的响应也不该有; HEAD 请求的响应可以带,但带的是「正文本该有多长」,实际不发正文。 HEAD 这个例外值得单独记:客户端拿到 Content-Length: 12345 但一个字节都没有,这是正常的, 因为 HEAD 约定的就是「只要首部」。 还有一条容易忽略:Content-Length 数的是编码之后的字节数。 gzip 压过的正文,这里写的是压缩后的长度,不是原文长度。

四、动手 Demo:正文 50 字节,声明值写四种

服务端正文固定 50 字节,只改 Content-Length 的声明值; 客户端严格按声明值读——不按连接关闭判断,只按那个数字判断。这样才能看出「声明错了」到底会怎样。

def read_by_cl(port, raw, declared, timeout=1.2):
    sk = socket.create_connection(('127.0.0.1', port), timeout=timeout)
    sk.sendall(raw)
    buf = b''
    while b'\r\n\r\n' not in buf:
        buf += sk.recv(4096)
    head, rest = buf.split(b'\r\n\r\n', 1)
    body = rest
    while len(body) < declared:        # 只认声明值,不认连接关闭
        try:
            body += sk.recv(4096)
        except socket.timeout:
            break                      # 等不全就超时收场
    return head, body[:declared]

实跑输出:

--------------------------------------------------------------------
Content-Length:说多少就得是多少
--------------------------------------------------------------------
  正文固定 50 字节,声明值分别写成 50、0、20、120:
  声明     实发     客户端收到   结果
  50       50       50           正常
  0        50       0            正文被当成 0 字节,剩下的没人要
  20       50       20           只收前 20 字节,剩下的是垃圾
  120      50       50           等不全,超时收场

  四行里只有第一行是对的。第二行声明 0 却发了 50 字节正文,
  在持久连接上后果更糟:那 50 字节会被当成下一个响应的开头,
  整条连接上的后续报文全部错位。这就是请求走私(request smuggling)的土办法。

  第三行声明 20 但发了 50,客户端收 20 就停,剩下的 30 字节污染连接;
  第四行声明 120 实际只有 50,客户端会一直等,等到超时或者连接被关。

  所以有一条硬规矩:用了分块传输(Transfer-Encoding: chunked)就不能再发
  Content-Length,两个同时出现时接收方该以 TE 为准——D064 已经实测过这一点。

四行结果:50 收 50、0 收 0、20 收 20、120 收 50 后超时。数字完全由声明值决定,与实发多少无关。 第二行最危险:声明 0,客户端压根不读那 50 字节,它们留在接收缓冲区里,下一次读会先读到这些垃圾。 第四行则是另一种体验:客户端一直等,等到 1.2 秒超时才放弃。 真实场景里如果服务端不主动关连接,这可能挂很久。 所以「多发」和「少发」都是错的,而且错法不同:少发是数据被丢,多发是连接被污染。

30 秒实验

  1. 把超时从 1.2 秒改到 0.2 秒,看第四行还收不收得到那 50 字节。
  2. 加上 Transfer-Encoding: chunked 同时保留 Content-Length,看客户端认哪个。
  3. 想一想:为什么 HEAD 的响应可以有 Content-Length 却没有正文?

常见误区

  1. 「Content-Length 是可选的,靠连接关闭也能判断边界」——HTTP/1.0 才那样,持久连接下必须给。
  2. 「多说一点没关系,客户端会自己停」——会污染连接,是走私的入口。
  3. 「它可以和 Transfer-Encoding 同时用」——同时出现时 CL 必须被忽略。
自测三问(点开对答案) 1. 声明 20、实发 50,客户端收几个字节? —— 20 个,多出来的 30 个留在连接里,会污染后续报文。 2. `Content-Length` 和 `Transfer-Encoding` 同时出现,认哪个? —— 认 Transfer-Encoding,Content-Length 必须被忽略。 3. gzip 压缩过的正文,这个字段写哪个长度? —— 压缩后的长度,因为数的是实际发出去的字节。

边界定好了,接下来是「这份内容和别的地址是什么关系」——Content-Location。

相关笔记