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

格式就一句:字段名 + 冒号 + 字段值。 但有四条规矩必须记住:名字不分大小写、值前后空白会被去掉、同名可以重复、每行必须有冒号。

📝 本节和前面几节的关系

上一节说首部在传什么,这一节说它怎么写。 这几条规矩看起来琐碎,实际是解析器最容易出 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 秒实验

  1. 把试验三的响应改成「取第一行」,看看和「取最后一行」差在哪,再想想哪个更容易被攻击者利用。
  2. 构造一个值里带逗号的 X-Date: Sat, 25 Oct 2026 06:00:00 GMT,用逗号盲拆,看拆成几段。
  3. 想一想:如果代理和服务端对「畸形行」的处理不一样,攻击者能干什么?

常见误区

  1. 「字段名要写成首字母大写」——写法自由,解析时必须不分大小写。
  2. 「同名首部后面覆盖前面」——应当合并成一个列表。
  3. 「见到逗号就拆」——日期和带参数的值本身含逗号。
自测三问(点开对答案) 1. `Content-Type` 和 `content-type` 是两个字段吗? —— 不是同一个字段,字段名不区分大小写。但值区分大小写。 2. 三行 `X-Tag: a/b/c` 用字典解析会发生什么? —— 后一个覆盖前一个,只剩最后那个值。正确做法是合并成列表。 3. 为什么不能对畸形首部行「宽容处理」? —— 代理与服务端理解不一致会让双方看到不同的请求,这是请求走私的成因。

格式说完了。下一节看书里怎么给这几十个字段分类。

相关笔记