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

Transfer-Encoding 说的是「正文在传输时被怎么加工过」。 多个值时从右往左读——最右边那个是最后一步。

📝 本节和前面几节的关系

上一节的尾块离不开它,这一节专门讲它。顺便说清它和 Content-Length 为什么不能共存。

一、读法:从右往左

Transfer-Encoding: gzip, chunked 的意思是:先 gzip 压缩,再切成块。 写的时候从左到右是加工顺序,所以读的时候要反过来——最靠右的是客户端第一步要解的。 客户端解的顺序是:先合并块(chunked 的逆操作),再解 gzip。 顺序反了,gzip 就没法从分块标记里认出自己的头。 这就是这个字段最容易出错的地方:它和 Content-Encoding 的「从右往左」含义都靠约定, 一不小心就写反。

二、和 Content-Length 是互斥的

两个字段都在回答同一个问题:正文在哪儿结束。一个是「长度固定」,一个是「用块标记」。 同时给出这两个,等于给了两个互相矛盾的答案。 按 RFC 7230,接收方必须以 Transfer-Encoding 为准,并且应当把这当成一个错误报文直接拒掉。 Demo 里故意让服务端发一个「Transfer-Encoding: chunked 加 Content-Length 写成双倍」的响应。 客户端按块解出来 840 字节,那个声称 1680 字节的长度被完全忽略。 为什么这个不一致这么危险:如果客户端信 Content-Length,服务端信 Transfer-Encoding, 两边看到的「正文有多长」就不一样,多出来的那部分字节会被当成下一个请求。 这就是经典的请求走私(request smuggling),历史上被用来绕过鉴权、污染缓存。

三、实测的三种情况

只有 chunked:14 块,共 840 字节,解出来 840 字节,与原文一致。 gzip 加 chunked:先压到 52 字节,切成 1 块。 客户端合并块后得 52 字节,再解 gzip 得到 840 字节,与原文一致。 压缩比很高,因为正文是同一句话重复了四十遍。 chunked 与 Content-Length 同时给:Content-Length 声称 1680 字节,分块解出来 840 字节。 客户端听的是 chunked。 注意 gzip 那一组的块数只有 1——因为压缩后总共才 52 字节,不到 64 字节的块大小,一块就装下了。

四、动手 Demo:三种传输编码的实测

同一个 840 字节的正文, 分别用「只有 chunked」「gzip 加 chunked」「chunked 与 Content-Length 同时给」三种方式发出去, 客户端手工解块、必要时再解 gzip,最后比对原文。

if mode == u'chunked':
    parts.append(u'Transfer-Encoding: chunked')
    body = TEXT
elif mode == u'gzip+chunked':
    parts.append(u'Transfer-Encoding: gzip, chunked')
    body = gzip.compress(TEXT, 6)
else:
    parts.append(u'Transfer-Encoding: chunked')
    parts.append(u'Content-Length: %d' % (len(TEXT) * 2))  # 故意写错
    body = TEXT
for i in range(0, len(body), 64):
    seg = body[i:i + 64]
    out += (u'%x\r\n' % len(seg)).encode() + seg + b'\r\n'
out += b'0\r\n\r\n'

实跑输出:

--------------------------------------------------------------------
chunked、gzip 加 chunked、以及与 Content-Length 的互斥
--------------------------------------------------------------------
  【只有 chunked】
    分块数                   14
    分块里装的字节           840
    解出来                   840 字节
    与原文一致               是

  【gzip 加 chunked】
    分块数                   1
    分块里装的字节           52
    解出来                   52 字节
    再解 gzip                840 字节
    与原文一致               是

  【chunked 与 Content-Length 同时给】
    分块数                   14
    分块里装的字节           840
    解出来                   840 字节
    Content-Length 声称      1680 字节(故意写了双倍)
    chunked 实际解出         840 字节
    该听谁的                 chunked。Content-Length 被完全忽略

  编码顺序从右往左读:Transfer-Encoding: gzip, chunked
  意思是「先 gzip 压缩,再切块」。客户端反向解:先合并块,再解 gzip。
  顺序写反了,客户端就解不出来——这是这类首部最常见的错。

  Transfer-Encoding 与 Content-Length 是互斥的。两个同时出现时,
  按 RFC 7230 必须以 Transfer-Encoding 为准,而且这是个应当被拒绝的报文——
  历史上它就是请求走私攻击的入口,所以现代服务端看到两个都带会直接断开。

只有 chunked:14 块,840 字节,解出来一致。 gzip 加 chunked:先压到 52 字节,只切成 1 块;客户端合并块后解 gzip 得 840 字节,一致。 压缩比 16 倍——因为正文是同一句话重复四十遍,这是最理想的情况。 两个都给:Content-Length 声称 1680 字节(故意的),分块解出 840 字节。 判决那一行写的是「chunked。Content-Length 被完全忽略」。 第三组正是现实里该被拒掉的报文。Demo 里客户端选择了照 chunked 解,所以没有出错; 但换成另一套实现,同样的字节可能被解成完全不同的东西。

30 秒实验

  1. 把 gzip, chunked 写成 chunked, gzip,看客户端的解顺序要不要跟着改。
  2. 把块大小从 64 改成 1024,看块数怎么变。
  3. 想一想:如果你写一个代理,收到一个同时带 TE 和 CL 的请求,最安全的处理是什么?

常见误区

  1. 「gzip, chunked 是先切块再压缩」——正好相反,从右往左读。
  2. 「两个都给时以 Content-Length 为准」——以 Transfer-Encoding 为准。
  3. 「Transfer-Encoding 和 Content-Encoding 是一回事」——前者管传输,后者管内容。
自测三问(点开对答案) 1. `Transfer-Encoding: gzip, chunked` 的加工顺序是什么? —— 先 gzip 压缩,再切块。读的时候从右往左,客户端先合并块再解 gzip。 2. 同时出现 TE 和 CL 时该听谁的? —— 听 `Transfer-Encoding`,并且应当把这个报文当作错误拒掉。 3. 为什么这个不一致会被利用做攻击? —— 两端对「正文在哪结束」判断不同,多出来的字节会被当成下一个请求,形成请求走私。

传输方式讲完了。下一个字段是「我想在这条连接上换个协议」。

相关笔记