Skip to main content

RFC 7541 - HPACK: Header Compression for HTTP/2

文档信息

  • RFC编号: 7541
  • 标题: HPACK: Header Compression for HTTP/2
  • 标题(中文): HPACK: HTTP/2的头部压缩
  • 发布日期: 2015年5月
  • 作者: R. Peon (Google), H. Ruellan (Canon)
  • 状态: PROPOSED STANDARD
  • 相关RFC: RFC 7540 (HTTP/2)

摘要 (Abstract)

HPACK是专为HTTP/2设计的头部压缩格式。它使用静态表和动态表进行索引编码,并使用霍夫曼编码压缩字符串,在保持安全性的同时显著减少HTTP头部的大小。

核心概念: HPACK通过索引表霍夫曼编码实现HTTP头部的高效压缩,同时避免了SPDY的CRIME攻击漏洞。

为什么需要HPACK?

HTTP/1.1的头部问题

HTTP/1.1请求示例:
GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)...
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session=abc123; user_id=456; ...
Referer: https://www.google.com/
Cache-Control: max-age=0

问题:
❌ 头部非常大 (常常500-2000字节)
❌ 每个请求都重复发送相同头部
❌ Cookie可能非常大
❌ 纯文本,未压缩
❌ 浪费带宽,增加延迟

示例统计:
- 头部大小: 800字节
- 页面100个资源
- 总头部: 80KB
- 在慢速网络上: 显著延迟

SPDY的压缩问题

SPDY使用gzip/deflate压缩头部:

优点:
✓ 压缩率高 (50-90%)
✓ 通用算法

问题:
❌ CRIME攻击 (Compression Ratio Info-leak Made Easy)
- 攻击者注入内容
- 观察压缩后大小
- 推断Cookie/Token

示例CRIME攻击:
1. 注入: Cookie: session=a
压缩后: 500字节
2. 注入: Cookie: session=b
压缩后: 500字节
3. 注入: Cookie: session=abc123 (猜对了!)
压缩后: 480字节 (更小!)

→ 通过压缩比泄露敏感信息

HPACK的解决方案

HPACK设计目标:
✓ 高压缩率
✓ 快速编码/解码
✓ 简单实现
✓ 抗CRIME攻击
✓ 低内存占用

关键特性:
1. 静态表 (预定义常用头部)
2. 动态表 (连接内头部缓存)
3. 增量编码 (基于索引)
4. 霍夫曼编码 (可选)
5. 不压缩敏感数据 (防CRIME)

HPACK核心概念

1. 静态表 (Static Table)

预定义61个常用HTTP头部:

索引 | 头部名称              | 头部值
-----|----------------------|--------
1 | :authority |
2 | :method | GET
3 | :method | POST
4 | :path | /
5 | :path | /index.html
6 | :scheme | http
7 | :scheme | https
8 | :status | 200
9 | :status | 204
10 | :status | 206
11 | :status | 304
12 | :status | 400
13 | :status | 404
14 | :status | 500
15 | accept-charset |
16 | accept-encoding | gzip, deflate
17 | accept-language |
18 | accept-ranges |
19 | accept |
20 | access-control-allow-origin |
...
61 | www-authenticate |

使用:
:method: GET → 索引2 (1字节!)
:status: 200 → 索引8 (1字节!)

完整静态表:

常用状态码:
8 :status 200
9 :status 204
10 :status 206
11 :status 304
12 :status 400
13 :status 404
14 :status 500

常用头部:
24 cache-control
31 content-type
38 etag
52 location
58 server
61 www-authenticate

HTTP/2伪头部:
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https

2. 动态表 (Dynamic Table)

在连接期间维护的头部缓存:

工作原理:
1. 首次发送完整头部
2. 添加到动态表
3. 后续请求用索引代替

示例:
第1个请求:
custom-header: custom-value
→ 完整编码 (32字节)
→ 添加到动态表索引62

第2个请求:
custom-header: custom-value
→ 索引62 (1字节!)

压缩率: 32字节 → 1字节 (97%压缩)

动态表结构:
┌─────────────────────────────┐
│ 索引62: custom-header: │
│ custom-value │
├─────────────────────────────┤
│ 索引63: another-header: │
│ another-value │
├─────────────────────────────┤
│ 索引64: ... │
└─────────────────────────────┘

管理:
- FIFO (先进先出)
- 有大小限制 (默认4096字节)
- 自动清理旧条目

3. 索引地址空间

静态表 + 动态表 = 统一索引空间

索引范围:
┌──────────────────┐
│ 索引 1-61 │ ← 静态表
├──────────────────┤
│ 索引 62+ │ ← 动态表
└──────────────────┘

查找顺序:
if 索引 <= 61:
return 静态表[索引]
else:
return 动态表[索引 - 61]

示例:
索引2 → 静态表 → :method GET
索引62 → 动态表第1项
索引65 → 动态表第4项

HPACK编码表示

三种编码类型

1. 索引头部字段 (Indexed Header Field)

完全匹配静态表或动态表的条目:

格式:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 1 | Index (7+) |
+---+---------------------------+

首位为1,表示索引编码
后7位表示索引值

示例:
:method: GET (静态表索引2)

编码:
1000 0010 (二进制)
= 0x82 (十六进制)

解码:
0x82 → 首位1 (索引编码)
→ 索引2
→ 静态表[2] = :method: GET

2. 带索引的字面头部 (Literal Header Field with Incremental Indexing)

新头部,需要添加到动态表:

格式 (索引名称):
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 1 | Index (6+) |
+---+---+-----------------------+
| Value Length (7+) |
+-------------------------------+
| Value String (Length octets) |
+-------------------------------+

格式 (字面名称):
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 1 | 0 |
+---+---+-----------------------+
| Name Length (7+) |
+-------------------------------+
| Name String (Length octets) |
+-------------------------------+
| Value Length (7+) |
+-------------------------------+
| Value String (Length octets) |
+-------------------------------+

示例:
cache-control: no-cache

名称在静态表索引24
值为新值 "no-cache"

编码:
01011000 (0x58, 索引24)
00001000 (值长度8)
6e 6f 2d 63 61 63 68 65 ("no-cache")

→ 添加到动态表索引62

3. 不索引的字面头部 (Literal Header Field without Indexing)

临时头部,不添加到动态表:

格式:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 | Index (4+) |
+---+---+---+---+---------------+
| Value Length (7+) |
+-------------------------------+
| Value String (Length octets) |
+-------------------------------+

用途:
- 不常用的头部
- 一次性头部
- 避免动态表污染

示例:
x-request-id: abc123 (一次性)

→ 不添加到动态表
→ 下次仍需完整编码

4. 永不索引的字面头部 (Literal Header Field Never Indexed)

敏感数据,绝不索引:

格式:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 | Index (4+) |
+---+---+---+---+---------------+

用途:
- Cookie
- Authorization
- 密码/Token
- 防止CRIME攻击

示例:
cookie: session=secret123

→ 永不添加到动态表
→ 每次都完整编码
→ 防止通过压缩泄露

安全性:
✓ 防止攻击者通过压缩比推断敏感数据
✓ 符合隐私保护要求

整数编码

HPACK使用可变长度整数编码:

N位前缀编码:

如果值 < 2^N - 1:
直接编码在N位中

如果值 >= 2^N - 1:
前缀全1
后续字节用128进制编码

示例 (5位前缀):

值10:
10 < 31 → 直接编码
01010

值127:
127 >= 31
01111 111 (前缀31)
01100000 (127-31=96, 128进制: 96)

计算:
前缀: 全1 = 2^N - 1
值 = 前缀 + 后续字节(128进制)

编码算法:

def encode_integer(value, prefix_bits):
"""编码整数"""
max_prefix = (1 << prefix_bits) - 1

if value < max_prefix:
return bytes([value])

# 前缀全1
result = bytes([max_prefix])
value -= max_prefix

# 后续字节 (128进制)
while value >= 128:
result += bytes([value % 128 + 128])
value //= 128

result += bytes([value])
return result

# 示例
encode_integer(10, 5) # b'\x0a'
encode_integer(127, 5) # b'\x1f\x60'
encode_integer(1337, 5) # b'\x1f\x9a\x0a'

字符串编码

字面字符串

格式:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| H | String Length (7+) |
+---+---------------------------+
| String Data (Length octets) |
+-------------------------------+

H位: 霍夫曼编码标志
- 0 = 原始字节
- 1 = 霍夫曼编码

示例 (原始):
"www.example.com"

编码:
0000 1111 (H=0, 长度15)
77 77 77 2e ... (原始字节)

霍夫曼编码

HPACK定义了专门的霍夫曼编码表:

常用字符 = 短编码
不常用字符 = 长编码

示例编码表 (部分):
字符 | 编码 | 位数
------|-----------------|-----
'0' | 00000 | 5
'1' | 00001 | 5
'2' | 00010 | 5
...
'a' | 00100 | 5
'e' | 00101 | 5
...
' ' | 010 | 3
'.' | 011 | 3
...

压缩示例:
"www.example.com"

原始: 15字节
霍夫曼: 约11字节 (压缩27%)

编码:
1000 1011 (H=1, 长度11)
[霍夫曼编码的位流]

霍夫曼编码实现:

# 预定义的霍夫曼编码表
HUFFMAN_TABLE = {
ord('a'): (0b00100, 5), # 'a' = 00100 (5位)
ord('e'): (0b00101, 5), # 'e' = 00101 (5位)
ord('w'): (0b01110, 5), # 'w' = 01110 (5位)
ord('.'): (0b011, 3), # '.' = 011 (3位)
# ... 完整表有256个条目
}

def huffman_encode(data):
"""霍夫曼编码"""
bits = []
for byte in data:
code, length = HUFFMAN_TABLE[byte]
bits.append((code, length))

# 转换为字节
result = []
buffer = 0
bits_in_buffer = 0

for code, length in bits:
buffer = (buffer << length) | code
bits_in_buffer += length

while bits_in_buffer >= 8:
bits_in_buffer -= 8
result.append((buffer >> bits_in_buffer) & 0xff)

# 填充
if bits_in_buffer > 0:
buffer <<= (8 - bits_in_buffer)
buffer |= (0xff >> bits_in_buffer)
result.append(buffer)

return bytes(result)

完整编码示例

示例1: 第一个请求

原始HTTP/2头部:
:method: GET
:scheme: https
:path: /index.html
:authority: www.example.com
cache-control: no-cache

HPACK编码:

1. :method: GET → 静态表索引2
编码: 82

2. :scheme: https → 静态表索引7
编码: 87

3. :path: /index.html → 静态表索引5
编码: 85

4. :authority: www.example.com
名称在静态表索引1,值为新值
编码: 41 0f 777777772e6578616d706c652e636f6d
(带索引,添加到动态表62)

5. cache-control: no-cache
名称在静态表索引24,值为新值
编码: 58 08 6e6f2d6361636865
(带索引,添加到动态表63)

完整编码 (十六进制):
82 87 85 41 0f 77 77 77 2e 65 78 61 6d 70 6c 65
2e 63 6f 6d 58 08 6e 6f 2d 63 61 63 68 65

大小: 原始~100字节 → 编码36字节 (64%压缩)

动态表状态:
[62] :authority: www.example.com
[63] cache-control: no-cache

示例2: 第二个请求 (复用动态表)

原始HTTP/2头部:
:method: GET
:scheme: https
:path: /style.css
:authority: www.example.com
cache-control: no-cache

HPACK编码:

1. :method: GET → 索引2
编码: 82

2. :scheme: https → 索引7
编码: 87

3. :path: /style.css → 新值
名称在静态表索引4
编码: 44 0a 2f7374796c652e637373

4. :authority: www.example.com → 动态表索引62
编码: be

5. cache-control: no-cache → 动态表索引63
编码: bf

完整编码:
82 87 44 0a 2f 73 74 79 6c 65 2e 63 73 73 be bf

大小: 原始~100字节 → 编码19字节 (81%压缩!)

动态表状态:
[62] :authority: www.example.com
[63] cache-control: no-cache
[64] :path: /style.css

动态表管理

大小限制

默认大小: 4096字节

计算:
条目大小 = 名称长度 + 值长度 + 32字节(开销)

示例:
:authority: www.example.com
= 10 + 15 + 32 = 57字节

动态表容量:
4096字节 / 57字节 ≈ 71个条目

溢出处理:
FIFO清理旧条目

动态表大小更新

格式:
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 | Max size (5+) |
+---+---+---+-------------------+

用途:
- 调整动态表大小
- 清空动态表 (设为0)

示例:
设置为8192字节:
001 11111 10111111 00011111
= 0x3f 0xbf 0x1f

清空动态表:
001 00000
= 0x20

Python完整实现

class HPACKEncoder:
"""HPACK编码器"""

def __init__(self, max_table_size=4096):
self.max_table_size = max_table_size
self.dynamic_table = []
self.dynamic_table_size = 0

def encode_headers(self, headers):
"""编码HTTP/2头部"""
result = bytearray()

for name, value in headers:
# 查找静态表
static_index = self.find_in_static_table(name, value)
if static_index:
# 完全匹配,使用索引
result.extend(self.encode_indexed(static_index))
continue

# 查找动态表
dynamic_index = self.find_in_dynamic_table(name, value)
if dynamic_index:
result.extend(self.encode_indexed(dynamic_index + 61))
continue

# 新头部,带索引编码
name_index = self.find_name_in_tables(name)
if name_index:
result.extend(self.encode_literal_indexed(
name_index, value
))
else:
result.extend(self.encode_literal_indexed_new(
name, value
))

# 添加到动态表
self.add_to_dynamic_table(name, value)

return bytes(result)

def encode_indexed(self, index):
"""编码索引头部"""
if index < 127:
return bytes([0x80 | index])

# 7位前缀整数编码
result = bytes([0xff])
index -= 127
while index >= 128:
result += bytes([index % 128 + 128])
index //= 128
result += bytes([index])
return result

def encode_literal_indexed(self, name_index, value):
"""编码带索引的字面头部 (索引名称)"""
result = bytearray()

# 01前缀 + 6位索引
if name_index < 63:
result.append(0x40 | name_index)
else:
result.append(0x7f)
# 编码 name_index - 63
index = name_index - 63
while index >= 128:
result.append(index % 128 + 128)
index //= 128
result.append(index)

# 编码值
value_bytes = value.encode('utf-8')
result.extend(self.encode_string(value_bytes))

return bytes(result)

def encode_string(self, data, huffman=False):
"""编码字符串"""
if huffman:
# 霍夫曼编码 (简化版)
encoded = data # 实际应该使用霍夫曼编码
result = bytes([0x80 | len(encoded)]) + encoded
else:
# 原始编码
if len(data) < 127:
result = bytes([len(data)]) + data
else:
result = bytes([0x7f])
length = len(data) - 127
while length >= 128:
result += bytes([length % 128 + 128])
length //= 128
result += bytes([length]) + data

return result

def add_to_dynamic_table(self, name, value):
"""添加到动态表"""
entry_size = len(name) + len(value) + 32

# 清理空间
while (self.dynamic_table_size + entry_size >
self.max_table_size and self.dynamic_table):
removed = self.dynamic_table.pop()
self.dynamic_table_size -= (len(removed[0]) +
len(removed[1]) + 32)

# 添加新条目
self.dynamic_table.insert(0, (name, value))
self.dynamic_table_size += entry_size

def find_in_static_table(self, name, value):
"""在静态表中查找"""
# 静态表查找逻辑
static_table = {
(':method', 'GET'): 2,
(':method', 'POST'): 3,
(':scheme', 'http'): 6,
(':scheme', 'https'): 7,
(':status', '200'): 8,
# ... 完整表
}
return static_table.get((name, value))

# 使用示例
encoder = HPACKEncoder()

headers = [
(':method', 'GET'),
(':scheme', 'https'),
(':path', '/'),
(':authority', 'example.com'),
]

encoded = encoder.encode_headers(headers)
print('编码结果:', encoded.hex())

参考文献

核心RFC:

  • [RFC 7540] Hypertext Transfer Protocol Version 2 (HTTP/2)
  • [RFC 7541] HPACK: Header Compression for HTTP/2 ← 本文档

安全:

  • CRIME Attack (2012)
  • BREACH Attack (2013)

相关标准:

  • [RFC 7231] HTTP/1.1 Semantics and Content
  • [RFC 9113] HTTP/2 (更新RFC 7540)

总结: HPACK是HTTP/2头部压缩的核心技术,通过静态表、动态表和霍夫曼编码实现高效压缩,同时避免了CRIME等安全漏洞。理解HPACK是深入掌握HTTP/2性能优化的关键!