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 秒实验
- 把
gzip, chunked写成chunked, gzip,看客户端的解顺序要不要跟着改。 - 把块大小从 64 改成 1024,看块数怎么变。
- 想一想:如果你写一个代理,收到一个同时带 TE 和 CL 的请求,最安全的处理是什么?
常见误区
- 「
gzip, chunked是先切块再压缩」——正好相反,从右往左读。 - 「两个都给时以 Content-Length 为准」——以 Transfer-Encoding 为准。
- 「
Transfer-Encoding和Content-Encoding是一回事」——前者管传输,后者管内容。
自测三问(点开对答案)
1. `Transfer-Encoding: gzip, chunked` 的加工顺序是什么? —— 先 gzip 压缩,再切块。读的时候从右往左,客户端先合并块再解 gzip。 2. 同时出现 TE 和 CL 时该听谁的? —— 听 `Transfer-Encoding`,并且应当把这个报文当作错误拒掉。 3. 为什么这个不一致会被利用做攻击? —— 两端对「正文在哪结束」判断不同,多出来的字节会被当成下一个请求,形成请求走私。传输方式讲完了。下一个字段是「我想在这条连接上换个协议」。
- ☐ (本节完,下一节 6.3.7 Upgrade)