做设备接入网关的时候 gorilla/websocket 一直用得很顺,直到我需要在一台机器上扛更多连接、并且要在帧这一层做审计(谁在发控制帧、有没有人用 1 字节分片把消息拖到几十 MB)。库把这些都封装掉了,我干脆把 RFC 6455 的数据帧部分读了一遍,写了个只依赖 io.Reader 的解析器。结论:协议本身非常小,难的从来不是"怎么解析",而是"边界在哪"。
一、先看清字节布局
RFC 6455 §5.2 的图,我按解析顺序加了注释:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | 16 位:7 位值 == 126 时出现 |
|N|V|V|V| |S| | 64 位:7 位值 == 127 时出现 |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key(4 字节,MASK=1 时才存在) |
+---------------------------------------------------------------+
| 接下来是 Payload Data(长度由上面的长度字段决定) |
+---------------------------------------------------------------+
最容易被忽略的是:头部长度不定(2 + 0/2/8 + 0/4),扩展长度与掩码键只在满足条件时出现。"先读 2 字节,再决定后面读几字节"是解析器唯一正确的形状。客户端发文本 "hello" 的真实字节:81 85 37 FA 21 3D 5F 9F 4D 96 4E——0x81 = FIN=1 + RSV=000 + opcode 0x1(text),0x85 = MASK=1 + 7 位长度 5,接着 4 字节掩码键 37 FA 21 3D,最后 5 字节 payload。去掩码就是 payload[i] ^ maskKey[i%4]:0x5F^0x37=0x68('h')、0x9F^0xFA=0x65('e')、0x4D^0x21=0x6C('l')、0x96^0xFA=0x6C('l')、0x4E^0x21=0x6F('o')。反过来,服务端回一个 300 字节二进制帧(服务端不加掩码)的头是 82 FE 01 2C:0xFE 表示 MASK=0 且 7 位值为哨兵 126,于是后面跟 2 字节大端 0x012C = 300。
二、逐字段:每一位在防什么
| 字段 | 取值与约束 |
|---|---|
FIN | 0 表示"消息还没完,后面跟 continuation";控制帧必须为 1 |
RSV1/2/3 | 未协商扩展时必须全 0,非 0 即协议错误(1002)。RSV1 被 permessage-deflate 协商走后表示"这帧 payload 是压缩的" |
opcode | 0x0 continuation、0x1 text、0x2 binary、0x8 close、0x9 ping、0xA pong;0x3~0x7、0xB~0xF 保留。控制帧判定是 opcode & 0x08 != 0,写成 op == 0x9 || op == 0xA 会漏掉 close 帧 |
Payload len | 7 位是"标量或哨兵":0~125 即长度;126 表示后面还有 2 字节 uint16;127 表示后面还有 8 字节 uint64,最高位必须为 0(长度不可能到 2^63) |
长度必须用最短编码:124 不能写成 126,0,124,65535 不能写成 8 字节形式。RFC 原文是 "the minimal number of bytes MUST be used to encode the length";各家是否强制不一样,我在自己的解析器里保留了检查,细节以 RFC 为准。
三、掩码防的到底是什么
先给结论:掩码不是加密。 key 就在同一帧里明文躺着,谁拿到字节流都能解开,它不提供任何机密性。它防的是"中间设备把 WebSocket 流当成 HTTP 解析":设想一个不认识 Upgrade: websocket 的透明代理或共享缓存,它只是把这条 TCP 连接上的字节当 HTTP 报文扫;而浏览器里的一段 JS 能完全控制 payload 内容。如果不掩码,这些字节原样上线,攻击者就能构造出看起来像完整 HTTP 请求(或响应)的序列,去污染共享缓存或做请求走私。
掩码把这件事拿掉了:4 字节 key 由浏览器随机选,写 JS 的人不知道会选到哪个 key,于是无法反推"我该填什么 payload,才能让上线后的字节等于我想要的序列"。攻击者失去的是对线上字节的控制权,而不是对自己数据的控制权。RFC 6455 §10.3 讲的就是这个动机,措辞以原文为准。
方向是硬性的:客户端→服务端必须掩码,服务端→客户端必须不掩码;服务端收到未掩码帧必须关连接,客户端收到掩码帧也必须关连接(§5.1)——所以解析器必须知道自己站在哪一侧。代价也说清楚:每个 payload 字节多一次 XOR(可向量化,但挡住了零拷贝,没法把 socket 缓冲直接递给上层);掩码键必须来自强熵源,RFC 要求不可预测,别用固定 key 或递增计数器。
四、Go 实现:只依赖 io.Reader
解析器只有四个字段:r io.Reader(不碰 net.Conn,所以 net.Conn/tls.Conn/bufio.Reader/bytes.Reader 都能接,单测也好写)、role(决定掩码方向)、maxFrame、maxMessage;Frame 结构是 FIN/RSV/Opcode/Masked/Payload,其中 Payload 已经去过掩码。下面代码里的 OpText/OpPing 这些常量就是它们的十六进制值,RoleServer/RoleClient 是方向枚举,ErrProtocol/ErrTooLarge 是包内错误。
func (p *Parser) readFrame() (Frame, error) {
var f Frame
// ① 固定头 2 字节。TCP 是流,绝不能用 Read 然后假设"一次就是完整一帧"
var h [2]byte
if _, err := io.ReadFull(p.r, h[:]); err != nil {
return f, err // io.EOF = 干净关闭;io.ErrUnexpectedEOF = 半帧就断了
}
f.FIN, f.RSV, f.Opcode = h[0]&0x80 != 0, h[0]>>4&0x07, h[0]&0x0F
f.Masked = h[1]&0x80 != 0
length := int64(h[1] & 0x7F)
// ② 未协商扩展时 RSV 必须为 0
if f.RSV != 0 {
return f, fmt.Errorf("%w: RSV=0b%b 但未协商扩展", ErrProtocol, f.RSV)
}
// ③ 保留 opcode 报错;控制帧(最高位为 1)不能分片且 payload ≤125
if f.Opcode > OpPong || (f.Opcode > OpBinary && f.Opcode < OpClose) {
return f, fmt.Errorf("%w: 保留 opcode 0x%X", ErrProtocol, f.Opcode)
}
if f.Opcode&0x08 != 0 {
if !f.FIN {
return f, fmt.Errorf("%w: 控制帧不能分片(0x%X)", ErrProtocol, f.Opcode)
}
if length > 125 {
return f, fmt.Errorf("%w: 控制帧长度 %d > 125", ErrProtocol, length)
}
}
// ④ 长度三档:7 / 7+16 / 7+64,一律大端
switch length {
case 126:
var ext [2]byte
if _, err := io.ReadFull(p.r, ext[:]); err != nil {
return f, err
}
if length = int64(binary.BigEndian.Uint16(ext[:])); length < 126 {
return f, fmt.Errorf("%w: 长度未用最短编码", ErrProtocol)
}
case 127:
var ext [8]byte
if _, err := io.ReadFull(p.r, ext[:]); err != nil {
return f, err
}
u := binary.BigEndian.Uint64(ext[:])
if u>>63 != 0 {
return f, fmt.Errorf("%w: 64 位长度最高位必须为 0", ErrProtocol)
}
if length = int64(u); length < 1<<16 {
return f, fmt.Errorf("%w: 长度未用最短编码", ErrProtocol)
}
}
// ⑤ 掩码键必须在 payload 之前读,否则后面全部错位
var maskKey [4]byte
if f.Masked {
if _, err := io.ReadFull(p.r, maskKey[:]); err != nil {
return f, err
}
}
// 服务端要求带掩码、客户端要求不带,两句话可以合成一次异或判断
if (p.role == RoleServer) != f.Masked {
return f, fmt.Errorf("%w: 掩码方向错误(role=%d masked=%v)", ErrProtocol, p.role, f.Masked)
}
// ⑥ 先卡上限再分配内存:顺序反了就是把 OOM 交给对端决定
if p.maxFrame > 0 && length > p.maxFrame {
return f, fmt.Errorf("%w: 帧长 %d > 上限 %d", ErrTooLarge, length, p.maxFrame)
}
payload := make([]byte, length)
if _, err := io.ReadFull(p.r, payload); err != nil {
return f, err
}
if f.Masked { // ⑦ 就地 XOR,全流程只有这一份 payload 副本
for i := range payload {
payload[i] ^= maskKey[i&3] // i&3 等价于 i%4
}
}
f.Payload = payload
return f, nil
}
第 ⑥ 步的顺序我改过两次。第一版是先 make 再校验上限,等于让对端用一个长度字段就想让我申请多少内存就申请多少——长度字段在校验之前是攻击者输入,不是数字。
五、分片:累计才是真正的上限
规则:首帧 opcode 为 text/binary 且 FIN=0,之后若干 opcode=0x0 的 continuation,最后一帧 FIN=1;控制帧可以插在分片中间,但它自己不能分片。所以上限必须按消息累计:
func (p *Parser) ReadMessage(onControl func(Frame) error) (Message, error) {
fragBuf, fragOp, started := []byte(nil), byte(0), false
for {
f, err := p.readFrame()
if err != nil {
return Message{}, err
}
if f.Opcode&0x08 != 0 { // 控制帧不参与拼装,立刻交给调用方
if onControl != nil {
if err := onControl(f); err != nil {
return Message{}, err
}
}
if f.Opcode == OpClose {
return Message{}, parseClose(f.Payload)
}
continue
}
switch f.Opcode {
case OpContinuation:
if !started {
return Message{}, fmt.Errorf("%w: 首帧不能是 continuation", ErrProtocol)
}
default: // OpText / OpBinary
if started {
return Message{}, fmt.Errorf("%w: 上一条消息未结束", ErrProtocol)
}
started, fragOp, fragBuf = true, f.Opcode, fragBuf[:0]
}
// 按累计长度判断:1 字节分片发十万次也会被挡住
if int64(len(fragBuf))+int64(len(f.Payload)) > p.maxMessage {
return Message{}, fmt.Errorf("%w: 消息累计超过 %d", ErrTooLarge, p.maxMessage)
}
if fragBuf = append(fragBuf, f.Payload...); !f.FIN {
continue
}
if fragOp == OpText && !utf8.Valid(fragBuf) { // 文本消息必须合法 UTF-8(§8.1)
return Message{}, fmt.Errorf("%w: text 不是合法 UTF-8", ErrProtocol)
}
out := make([]byte, len(fragBuf))
copy(out, fragBuf) // fragBuf 会复用,必须拷贝
return Message{Opcode: fragOp, Data: out}, nil
}
}
onControl 回调是故意的:ping 必须回 pong,而回 pong 必须发生在读循环里,不能排在上层业务之后——上层可能在查库、在等锁,而 ping 的语义是"我还在",让心跳等业务等于把心跳变成业务的从属。
六、close 帧只有三种合法形态
payload 要么空、要么 ≥2 字节,绝不会是 1 字节:
func parseClose(payload []byte) error {
switch len(payload) {
case 0:
return &CloseError{Code: 1005} // 没带状态码,等价于 1005(No Status Rcvd)
case 1:
return fmt.Errorf("%w: close 帧 payload 只有 1 字节", ErrProtocol)
}
code := binary.BigEndian.Uint16(payload[:2])
if !validCloseCode(code) {
return fmt.Errorf("%w: 非法 close 状态码 %d", ErrProtocol, code)
}
if !utf8.Valid(payload[2:]) {
return fmt.Errorf("%w: close reason 不是合法 UTF-8", ErrProtocol)
}
return &CloseError{Code: code, Reason: string(payload[2:])}
}
状态码(§7.4):1000 正常关闭、1001 going away、1002 协议错误、1003 收到不支持的数据类型、1007 payload 类型错(比如 text 不是 UTF-8)、1008 策略违规、1009 消息过大、1010 扩展没协商上、1011 服务端内部错误,3000~4999 留给应用。1004/1005/1006/1015 绝对不能出现在线上:1005 是"这条 close 没带状态码"、1006 是"我本地观测到异常断开",都是本地状态;1012~1014 是 IANA 后来登记但 RFC 未定义的,接不接看对端。另外 reason 是 UTF-8 文本,而控制帧 payload ≤125 字节,所以 reason 最多 123 字节。
七、什么时候用 gorilla,什么时候值得手写
| 需求 | gorilla/websocket | 手写解析器 |
|---|---|---|
| 分片自动拼装 | 库已经做了 | 自己累计 |
| 控制帧默认行为 | 默认 handler 会回 payload 相同的 pong、回一个 close(细节以包源码为准) | 自己写 onControl |
| 把控内存 | SetReadLimit 是每条消息的上限,内部仍会为整条消息分配;想复用缓冲要接 NextReader | 分配与复用完全自己定,稳态可做到零分配 |
| 自定义调度 | 一连接一 goroutine + NextReader | 可以把多个连接的帧喂进自己的事件循环/批处理管道 |
| 自定义扩展 | RSV 由库管,不好插手 | 自己定义 RSV1/2/3 的含义(比如自研 permessage-deflate) |
我的选择:业务客户端与普通服务端用库;只有当我要在帧层做决策(审计、限流、指标、内存预算),或者要把连接生命周期从 goroutine 里解耦出来时,才手写。 顺手提一句生态:golang.org/x/net/websocket 已废弃,nhooyr.io/websocket 改名成了 github.com/coder/websocket,选型以各自官方文档为准。
八、我踩过的坑
- 不设上限等于不设防。 对端一个长度字段就能让我分配任意大内存;设得太大同样等于没设——上限同时也是"攻击者能稳定占用的内存量"。
- 回 pong 别阻塞在写操作后面。 一个连接同一时刻只能有一个 writer,业务正在写大消息时 pong 被排在后面,对端先超时了。要么给控制帧单独通道,要么用文档明确允许与其他方法并发调用的控制帧写接口(比如
gorilla的WriteControl),要么给数据写加短 deadline。 - TCP 半包让
io.ReadFull成为必需品。 用Read读 2 字节头在回环上几乎总是够,换成 TLS、或对端用 Nagle 攒小帧、或负载一变,就会出现"只读到 1 字节"。反面同样致命:一次Read可能返回多于一帧的字节,多出来的部分就是被你丢掉的下一帧。 - close 是握手不是单方面挂断。 收到 close 且自己没发过,必须回一个(通常 echo 同一状态码);服务端应在发完 close 后立刻关 TCP,客户端等对端关。直接
Close()就走的后果是对端只看到1006 异常关闭。 - 不要在 Read 循环外并发写数据帧,也不要没有读超时。 两个 goroutine 各写半个帧,对端解析就崩了;而对端断网(不是断连)时你没有读超时也感知不到——标准做法是定期 ping,并把读 deadline 当 idle 超时,每收到一帧就往后推。
九、验证:手工发一个未掩码帧
握手可以先对一下:RFC 6455 §1.3 的示例 key dGhlIHNhbXBsZSBub25jZQ== 对应的 Sec-WebSocket-Accept 是 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。
curl -i -N --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1:8080/ws
curl 发不出裸帧,但 nc 可以。把手写的未掩码帧喂进去,看服务端是否按 RFC 关连接:
{ printf 'GET /ws HTTP/1.1\r\nHost: h\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n'
sleep 0.3; printf '\x81\x05hello'; sleep 0.3 # 未掩码文本帧:MASK=0
} | nc 127.0.0.1 8080 | xxd | head -30
正确实现应该回一个 close 帧再断连,线上字节近似 88 02 03 EA:0x88 = FIN=1 + opcode 0x8,0x02 是长度,0x03EA = 1002 协议错误。把 \x81\x05hello 换成掩码版本 \x81\x85\x37\xFA\x21\x3D\x5F\x9F\x4D\x96\x4E,同一个服务端应当正常回显。这两条用例一次把掩码方向、掩码解码、长度档位都验证了。(Windows 上不一定有 nc,用 ncat 或十几行 Go 客户端代替即可。分片与控制帧的完备性我没打算自己凑用例——Autobahn TestSuite 就是干这个的,我的第一版在它上面跑出过 4 个 FAIL,全是"控制帧 payload > 125 没拦住"这类边界。)
小结
数据帧这部分,我更愿意把 RFC 6455 理解成一份校验清单而不是编码格式:长度必须最短编码、RSV 必须为 0、控制帧不能分片且 ≤125 字节、掩码方向必须正确、close 状态码必须在允许集合内、text 必须是合法 UTF-8。写解析器就是把这六条 MUST 一条条落到 if 上。
真要说收获,是掩码那一段:我一直以为它跟安全有关,读完才发现它防的是"中间设备认错协议",手段是剥夺攻击者对线上字节的控制权,而不是让字节变得不可读。这个区分让我后来看别的协议时多了个角度——很多"看起来像加密"的机制,其实是在给中间设备制造不可预测性。