格式就一句:字段名 + 冒号 + 字段值。 但有四条规矩必须记住:名字不分大小写、值前后空白会被去掉、同名可以重复、每行必须有冒号。
上一节说首部在传什么,这一节说它怎么写。 这几条规矩看起来琐碎,实际是解析器最容易出 bug 的地方。
一、规矩一:名字不分大小写
Host、host、HOST、hOsT 是同一个字段。
所以服务端读的时候必须归一化——绝大多数实现是统一转小写。
Demo 里发的是 hOsT 和 user-AGENT,解析出来是 host 和 user-agent。
名字不分大小写,值分。
Content-Type: text/html 和 Content-Type: TEXT/HTML 在小写化的那一刻就不一样了,
虽然 MIME 类型本身也不区分大小写,但值的语义由字段自己定。
二、规矩二:值前后的空白会被去掉
X-Pad:\t\tabc\t 解析出来是 abc。冒号后面可以有任意多空白,值末尾的空白也会被裁掉。
这条规矩的实际意义:你不能靠空格传递信息。
有人试过用值末尾的空格做标记,结果被中间某个代理吃掉了。
值的中间空白原样保留——text/html; charset=utf-8 里那个空格不能丢。
三、规矩三:同名可以出现多次,也可以逗号合并
这两句是等价的:
X-Tag: a
X-Tag: b
X-Tag: c
X-Tag: a, b, c
所以正确的解析方式是合并,不是覆盖。Demo 里用普通字典解析,最后只剩下 c——前两个被吃掉了。
这是自己写解析器最常见的 bug。
但合并有个陷阱:有些值本身含逗号。
日期 Sat, 25 Oct 2026 06:00:00 GMT 里就有逗号,
Accept 的 text/html;q=0.9, application/json 里也有。所以不能见到逗号就拆。
正解是:只有明确知道这个字段是逗号分隔列表的(Accept、Cache-Control、Via 等),才按逗号拆;
其他字段要保留成一条完整的值。
四、规矩四:每行必须有冒号
没有冒号的行不是首部。按 RFC 7230,这种请求应当直接拒掉。
Demo 里发一行 This line has no colon,服务端回了 400 Bad Request,
并把那一行原样写进了响应正文,方便排查。
为什么必须这么严?因为「宽松解析」是安全漏洞的温床。
如果服务端忽略畸形行、代理却按自己的方式理解,两边看到的请求就不一样了——这就是请求走私的原理。
另外有个已经废弃的写法叫折行(obs-fold):一个字段的值太长,用回车加空格续到下一行。
HTTP/1.1 明文规定不许生成,收到时要么拒绝、要么把折行展开成一个空格。现代实现基本都直接拒。
五、动手 Demo:五个试验,把四条规矩各试一遍
依次发大小写混写的字段名、带大量空白、重复三行、逗号合并、以及一行没有冒号的垃圾, 看服务端解析出来的结果。
def responder(raw, st):
lines = X.head_lines(raw)
bad = [ln for ln in lines[1:] if u':' not in ln]
seen.append((lines[1:], dict(H.parse_headers(raw)[1])))
if bad:
return reply(u'HTTP/1.1 400 Bad Request', [],
(u'首部行缺少冒号:' + bad[0]).encode(u'utf-8'))
return reply(u'HTTP/1.1 200 OK', [], b'ok')
实跑输出:
--------------------------------------------------------------------
字段名冒号字段值:四条容易被忽略的规矩
--------------------------------------------------------------------
试验一:字段名大小写
原始行 'hOsT: www.example.com'
原始行 'user-AGENT: demo'
解析后键名 host | user-agent
结论 字段名不区分大小写,解析后统一小写
试验二:冒号后面的空白
原始行 'X-Pad:\t\tabc\t'
x-pad 的值 'abc'
host 的值 'www.example.com'
结论 值前后的空白会被去掉,中间的不动
试验三:同名首部重复出现
发了三行 X-Tag a、b、c
字典解析后只剩 'c'
说明 普通字典会覆盖,正确做法是合并成 a, b, c
试验四:逗号合并写法
一行三个值 'text/html, application/json, */*'
拆开 text/html | application/json | */*
注意 有些值本身含逗号(日期、带参数的),不能盲拆
试验五:这行不是首部
发一行没有冒号的内容 服务端回了 HTTP/1.1 400 Bad Request
规矩 首部行必须有冒号,否则整段报文不合法(该回 400)
四条规矩:名字不分大小写、值前后空白会被去掉、
同名可以重复也可以逗号合并、每一行必须有冒号。
写解析器时最容易踩的是第三条和第四条——前者会静默丢数据,
后者会让畸形请求被当成正常请求放进来。
试验一:发 hOsT 和 user-AGENT,解析出来是 host 和 user-agent。名字归一化了。
试验二:X-Pad:\t\tabc\t 得到 'abc',前后的空白没了。
同时 Host: www.example.com 也裁成了 www.example.com。
试验三:三行 X-Tag 用普通字典解析只剩 'c'——这就是覆盖而不是合并的后果。
试验四:Accept: text/html, application/json, */* 一行拆出三个值,这个字段确实是逗号列表,
拆是对的。
试验五:那行没冒号的内容被拒了,返回 400,
响应正文里写着 首部行缺少冒号:This line has no colon。
30 秒实验
- 把试验三的响应改成「取第一行」,看看和「取最后一行」差在哪,再想想哪个更容易被攻击者利用。
- 构造一个值里带逗号的
X-Date: Sat, 25 Oct 2026 06:00:00 GMT,用逗号盲拆,看拆成几段。 - 想一想:如果代理和服务端对「畸形行」的处理不一样,攻击者能干什么?
常见误区
- 「字段名要写成首字母大写」——写法自由,解析时必须不分大小写。
- 「同名首部后面覆盖前面」——应当合并成一个列表。
- 「见到逗号就拆」——日期和带参数的值本身含逗号。
自测三问(点开对答案)
1. `Content-Type` 和 `content-type` 是两个字段吗? —— 不是同一个字段,字段名不区分大小写。但值区分大小写。 2. 三行 `X-Tag: a/b/c` 用字典解析会发生什么? —— 后一个覆盖前一个,只剩最后那个值。正确做法是合并成列表。 3. 为什么不能对畸形首部行「宽容处理」? —— 代理与服务端理解不一致会让双方看到不同的请求,这是请求走私的成因。格式说完了。下一节看书里怎么给这几十个字段分类。
- ☐ (本节完,下一节 6.2.3 4种HTTP首部字段类型)