Skip to content

WebSocket:未拒绝未掩码的客户端帧、也未校验文本帧 UTF-8(两条 RFC 6455 MUST) #8

Closed

Description

@sercebr

两条 RFC 6455 的 MUST 没有实现:未掩码的客户端帧、非法 UTF-8 的文本帧

在 master(1.1.7,本地从源码构建)上打 WebSocket 协议一致性时发现的。解析器只对这两条不做校验,其余都做了 —— 所以这不是"WS 实现不完整",而是两个具体的检查漏了。

1. 未掩码的客户端帧被当成正常消息

发送:一个掩码位为 0 的合法 TEXT 帧(内容 "unmasked")
实测:服务端把它原样回显了(收到 data b'unmasked')
期望:以 1002 关闭连接

RFC 6455 §5.1:

The server MUST close the connection upon receiving a frame that is not masked.
... a server MUST use the 1002 status code.

掩码存在的理由就是防中间代理缓存投毒(§10.3),所以服务端不强制等于把这条防护让掉。

2. 非法 UTF-8 的文本帧被原样回显

发送:TEXT 帧,负载是 \xff\xfe\xfd(不是合法 UTF-8)
实测:服务端原样回显(收到 data b'\xff\xfe\xfd')
期望:以 1007 关闭

RFC 6455 §8.1:

When an endpoint is to interpret a byte stream as UTF-8 but finds that the byte stream is not, in fact, a valid UTF-8 stream, that endpoint MUST Fail the WebSocket Connection.

对照组:其余校验都是对的

检查 服务端行为
RSV 位被置(未协商扩展) ✅ close 1002
保留 opcode(0x3) ✅ close 1002
分片消息(首片 + 续片 + 末片) ✅ 正确重组后回显
100 KB 消息(64 位长度编码) ✅ 完整回显
Close 帧 ✅ 以 1000 应答

所以校验框架是在的,只是这两条没有。

代码位置与建议改法

掩码:src/web/uvcpp_ws_connection.cpp 里控制帧/数据帧是在一处分支里分开处理的(switch (frame.opcode) 那一支是控制帧,之后是数据帧)。掩码检查要在这个分叉之前做 —— 它对所有帧类型都适用(控制帧也必须掩码,§5.5):

// 服务端:客户端发来的帧必须掩码(RFC 6455 §5.1)。
// 注意方向:客户端一侧反过来 —— 服务端发来的帧必须**不**掩码,同样是 1002。
if (is_server_side() && !frame.masked) {
  protocol_error(ws_close_code::PROTOCOL_ERROR, "client frame must be masked");
  return;
}

(uvcpp_ws_parser 现在是"有掩码就解、没有也不拒" —— parse_frame_header 里 if (frame_.masked) { state_ = MASK_KEY; ... },缺一个方向性的要求。因为解析器两边共用,这个判断放在连接层更合适,需要知道自己是服务端还是客户端。)

UTF-8:deliver_message() 里,解压之后、调 on_text_ 之前,正好是校验点 —— 校验的必须是应用将要看到的那份字节(压缩时必须验解压后的):

if (op == ws_opcode::TEXT) {
  if (!is_valid_utf8(data, len)) {
    message_payload_.clear();
    protocol_error(ws_close_code::INVALID_PAYLOAD, "text frame is not valid UTF-8");
    return;
  }
  ...
}

INVALID_PAYLOAD(1007)这个关闭码文件里已经在用了(permessage-deflate 解压失败那一支),所以不用新增任何基础设施。

(严格说 §8.1 还要求分片文本边收边校验,好在坏字节到达时就失败;但"消息收完再验"已经满足"MUST Fail the Connection"这条主要要求,实现成本也低得多。可以先把这条补上。)

复现

两条都不需要特殊工具,一个裸 socket 就能发:

# 未掩码的 TEXT 帧(fin=1, opcode=1, mask=0, len=8)
sock.sendall(bytes([0x81, 0x08]) + b"unmasked")
# 非法 UTF-8 的 TEXT 帧(fin=1, opcode=1, mask=0, len=3)
sock.sendall(bytes([0x81, 0x03]) + b"\xff\xfe\xfd")

完整探查脚本(7 条,含上面那组对照)我这边有,需要的话可以贴上来或者整理成一个 tests/functional/ 下的用例。

影响评估

两条都是一致性缺陷,不是内存/崩溃类问题:

  • 未掩码:服务端本身不会因此出错,但它放掉的是"客户端必须掩码"这条约束 —— 在中间代理存在时,这正是掩码要防的场景。
  • 非法 UTF-8:服务端只是转发字节,自己不受影响;但一个把文本原样转给浏览器的应用(聊天室这类)会把非法字节传下去,客户端那边才炸。

两条都不难改,改动集中在 uvcpp_ws_connection.cpp,不需要动解析器。

Activity

  1. sercebr commented on Sep 19, 2026

    @sercebr
    CollaboratorAuthor

    修复在 #9。

    补这两条检查时还暴露出第三条同源缺陷:框架自己的客户端也从不掩码(send_data() 造帧时不设 f.masked),所以两侧发的都是未掩码帧 —— 这在服务端没人检查时一直看不出来。一加上服务端检查,test_web_ws_func 的 echo 与 test_web_ws_client_api_func 当场失败,因为框架客户端发不出一帧自己的服务端愿意收的数据。所以 #9 把客户端掩码也一并补了(§5.1 的另一半,同样从没实现过)。

    验证:仓库自带的 7 个 WS 用例全部通过,另有独立探针 7/7(协议一致性)与 19/19(UTF-8 边界)。

  2. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    修复随 #9 合并落地(87df278 + ef5207e + fa2b97d,1.1.25)。

    两条 RFC 6455 MUST(§5.1 客户端帧必须掩码、§8.1 文本帧负载必须合法 UTF-8)以及你顺带挖出来的第三条同源缺陷(框架客户端从不掩码)现在都是真的实现了。

    合并时我补了一条你没写的用例:未掩码的控制帧。它和数据帧那条钉的是两个不同位置 —— 一条钉"检查在不在",一条钉"检查放对了没有"。变异证明它非空转:把检查改成放走控制帧(&& !frame.is_control_frame()),数据帧那条照旧 PASS、只有它 FAIL。

    本机:0 个 error C/error LNK,77/77 ctest,页面堆门禁 77 个用例裸跑 + 完整页堆各一遍通过。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions