Repository navigation
fix(ws): 服务端强制客户端掩码、文本消息校验 UTF-8,并让客户端真的掩码 - #9
Conversation
两条 RFC 6455 的 MUST 此前没有实现,外加一条在补它们的过程中暴露出来的
**对称缺陷**。
一、服务端未拒绝未掩码的客户端帧(§5.1)
"The server MUST close the connection upon receiving a frame that is not
masked. ... a server MUST use the 1002 status code."
实测:发一个掩码位为 0 的合法 TEXT 帧,服务端把它当正常消息回显了。
掩码的用途是防中间代理缓存投毒(§10.3),服务端不强制等于把这条防护让掉。
检查放在 `on_ws_frame()` 的**最前面** —— 它对所有帧类型都成立,控制帧也不例外
(§5.5),放进控制帧/数据帧的分叉之后就只能盖住一半。
二、文本消息未校验 UTF-8(§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_."
实测:发 `\xff\xfe\xfd` 的 TEXT 帧,服务端原样回显。校验点在
`deliver_message()` 里**解压之后、交给 on_text 之前** —— 应用看到的是解压后的
字节,校验就必须落在同一份字节上。
校验器覆盖的不只是"形状不对",还有三处**语法合法但语义非法**的:overlong
编码、UTF-16 代理区 U+D800–U+DFFF、超出 U+10FFFF。只判首字节与续字节的形状
会让这三类漏过去,而它们正是"能过校验却仍不是合法 UTF-8"的全部来源。
三、补上面两条时暴露出来的:**框架自己的客户端也从不掩码**
`send_data()` 造帧时从来不设 `f.masked`,所以两侧发的都是未掩码帧。这在
服务端那一侧因为没人检查而一直没暴露 —— 一加上第一条检查,
`test_web_ws_func` 的 echo 与 `test_web_ws_client_api_func` **当场失败**,
因为框架客户端发不出一帧自己的服务端愿意收的数据。
所以这一条是必须配套的:新增 `apply_mask()`,在四个发送出口(数据 / PING /
PONG / CLOSE)各过一手。**没有放进 `send_frame()` 里统一做**是因为那个函数的
形参是 `const uvcpp_ws_frame&`,要改就得先拷一份 —— 而帧里带着 payload,
每帧一次 O(n) 拷贝不值得;四个出口各一行更划算。
掩码密钥取「线程本地 mt19937_64,种子来自 random_device」,**不是密码学强度**,
但对 §5.3 要求的"不可预测"是够的,同时避免在每帧的发送路径上做 4 次系统调用
(逐帧调 random_device 在 Linux 上就是每帧 4 次读 /dev/urandom)。
`uvcpp_ws_connection` 的构造函数补上 `bool is_server`,**必填、无默认值** ——
掩码的方向性要求两侧是反的,搞反了任意一侧都直接不可用,所以让它必须被写出来。
四个构造点(server / client ×2 / 两个测试)都已更新。
验证
----
- **仓库自带的 7 个 WS 测试全部通过**(`ws_func` / `ws_parser` / `ws_ext` /
`ws_ownership` 31 checks / `ws_reassembly` / `ws_client_api` /
`ws_client_close`)。其中 `ws_func` 的 echo 用例通过本身就证明客户端确实在
掩码了 —— 服务端现在要求掩码,不掩的话这条必然失败。
- 外部探针:协议一致性 7/7(未掩码→1002、非法 UTF-8→1007、RSV→1002、
保留 opcode→1002、分片重组、100 KB 消息、Close 应答);
UTF-8 边界 19/19(9 条合法含 2/3/4 字节与各档边界值、emoji、中文;
10 条非法含三种 overlong、代理区、超 U+10FFFF、两种截断)。
- 功能基线 53/55(另外 2 条是测试脚本里写死的上传目录,与本次改动无关)。
过程中踩到并记一笔的两件事
--------------------------
1. 第一版只加了服务端的两条检查就提交,**把仓库自己的两个 WS 用例打挂了**。
是靠"把源码还原成未修改版重编、再跑一遍"拿到基线才确认是自己的问题 ——
否则很容易当成"本地构建配置的差异"放过去。
2. 改 `uvcpp_ws_sessions.h` 里的文档注释时写了 `new uvcpp_ws_connection(tcp,
/*is_server=*/true)`,那个 `*/` **把 `/** ... */` 提前闭合了**,整份头文件
语法错误。文档注释里不能出现 `/*...*/` 形式的行内注释。
|
CI 我点了 approve,已经在跑了,出结果我再看。 顺便想说句正经的谢谢 —— 你应该是这个项目第一个来提 issue 和 PR 的人,而且一上来就是这么一整包。 这几天我们把你提的 4 个 PR 和 5 个 issue 逐条对着源码核过了。每一条的核心结论都成立,没有一条是误报:#7 的 更想说的是你怎么找出来的。 你是拿一个真实的 web 服务在跑 —— 看 RSS 曲线、对响应体大小做二分定位到那个断崖、再做"只差一个构建开关"的对照实验、甚至给两条分支各加计数器把命中率数出来。这一套比仓库自带的那批单元/功能测试严格得多。我们自己的用例里正好有几个现成的反面教材:
"测试跑过"和"测试能抓住问题"是两件事,你补的正是后者。这种从现象反推到代码、还顺手把对照组做全的做法,比提几个补丁本身有价值得多。 剩下的 #1 #2 #4 #5 #6 #8 都在这一轮的清单里。#4 和 #6 你留了选择题的那几条,等我们把方向定下来就回你 —— 不会让它们烂在那儿。如果哪一条我们的结论跟你不一致,也会直接在下面说清楚为什么。 你先忙你的,不用等我们。 |
|
谢谢,也谢谢你把 #7 合了。 说来有点不好意思 —— 我其实不是冲着找 bug 来的。最初是偶然刷到这个项目,觉得有意思,就想知道它到底扛不扛得住真实服务:RPS 能到多少、并发能顶多高、连跑一晚上会不会漏内存、上了 TLS 还剩多少。于是拿它起了个最普通的 web 服务,登录、静态文件、上下传、WebSocket 都摆上,照着一个正常网站的用法去打。 结果那几个问题全是被我自己的数字逼出来的。看到 RSS 每下载一次 256 MB 的文件就涨 260 MB,没法当没看见;自己测出每秒 8 万请求的服务,静态文件并发一过 16 就大面积 503,那这个吞吐数字本身也就不可信了。所以每条都是先觉得"这数不对",再往回倒推,一直倒到能完整解释为止。对照组和那几条计数器都是被逼出来的 —— 成因没钉死,我不敢把结论写进报告。 但更想说的是:真正难做对的地方,这个项目比我预期的好。协议层那一堆(请求走私的四种变体、目录穿越的十一种编码、慢速攻击、 顺带说一句,你列的那三个测试盲区(尺寸数组恰好卡在 262144、泄漏分支在正常路径上 100% 命中、"自测自"的 WS 往返)我看了挺有感触 —— 我前面能撞上,很大程度上也只是因为我站的位置不一样:我是从外面拿一个真服务在打,而不是从里面按接口逐个验。两种视角确实是互补的,你没必要因为这个觉得哪里做得不够。 会继续关注这个项目,HTTP/2 那条线挺期待。#4 和 #6 不着急,你们定方向,回头需要按哪个方案做我接着弄。 祝开发顺利。 |
|
CI 结果出来了:12 个 job 红。先说结论 —— 你这两条检查本身是对的,问题出在别处。分三部分说,证据都附上。 一、编译不过:
|
| 库 | 结果 |
|---|---|
| 校验开着(= #9 原样) | 4 条 FAIL |
| 校验摘掉(变异) | ALL PASS |
| 还原 | 4 条 FAIL |
成因在负载:这三条用的是 incompressible(n)(一个 LCG 吐的随机字节),而它们按 TEXT(0x1)+ rsv=0 发出去,不走 permessage-deflate。随机字节必然不是合法 UTF-8,撞上你新加的 §8.1 校验 → 1007 关连接 → 消息到不了。
所以不是你的检查写错了,是这三条用例一直在往 TEXT 帧里塞非法 UTF-8。 最有力的旁证是同一个随机负载的 split_frame_across_reads 是 PASS 的 —— 它带 rsv1=0x40,你的校验落在解压之后的正文上,那里的字节是合法的。这恰好反过来证明你把校验点放在「解压之后、交给 on_text 之前」是选对了。
(你正文里写过,第一版只加服务端检查时仓库自己两个用例当场挂 —— 这就是第三个。你没编到这个文件,所以没看见。)
改法在测试侧:要么这几处改用 BINARY(0x2),要么让 incompressible() 吐合法 UTF-8 的随机序列(得保证它仍然压不动、帧仍然跨读)。哪个更合你的意,你来定。
三、想先听你一句意见:is_server 该不该给默认值
现在它是必填、无默认。你的理由我认同 —— 掩码方向搞反了任意一侧都直接不可用,所以宁愿让它必须被写出来,而不是靠默认值猜。
但这次的账单也摆在眼前:仓库内部漏一处,就是 12 个 job 全红;而这是公开头文件里的类,第三方直接 new uvcpp_ws_connection(tcp) 的代码会同样编不过,且没有任何提示。
加一个 = false(或 = true,看哪边更保守)这类错误就一条都不会有,代价是「搞反方向」从编译期退回运行期 —— 不过真搞反了,框架自己的 WS 往返用例应该会立刻抓到。
这条我们不想替你拍:你在真实项目上的实测经验比我们多,先说你的判断,你倾向哪边我们就按哪边来。
`is_server` 改成必填之后,构造点一共是**四个**(server / client ×2 / 测试)。 我 PR 正文里写的"两个测试文件"漏了这一个 —— `web_ws_deflate_func.cpp` 里的 `uvcpp_ws_connection conn(&dead);`。16 个 job 里 12 个死在它上面。 **为什么漏掉它**:我本地那份构建开了 web 但**没开 zlib**,于是这个文件根本没 参与编译(同批还有两个 web_app_ws 用例也是因此编不过,我当时看到了但归给了 "构建配置差异",没意识到它同时意味着"这个文件我没检查到")。 教训是数调用点的口径:我是按"哪些测试文件我改过"数的,而正确做法是对全仓做 一次 grep(`new uvcpp_ws_connection(` / `uvcpp_ws_connection conn(`)—— 那样 是不会漏的。补完之后已经按这个口径重新扫过一遍。 角色取 false:这条用例只是"往一个死连接发帧、断言失败被上报",不涉及真实 对端,两种角色都能过;按"独立连接主动发帧"的样子当成客户端更自然。
94e35ad to
c71ed41
Compare
|
分析得很到位,尤其那个变异(把校验摘掉看 4 条是否转绿)—— 这是把"是不是我这条检查造成的"钉死的最干净做法。三条都回一下。 一、漏掉的构造点已经补了(
|
| 负载 | 压缩比 |
|---|---|
| LCG 随机字节(现在用的) | 1.000 |
| 合法 UTF-8:码点均匀取 U+0000–U+10FFFF | 0.755 |
| 合法 UTF-8:只取 2 字节区 U+0080–U+07FF | 0.809 |
| 合法 UTF-8:只取 3 字节区 U+0800–U+FFFF | 0.790 |
| 合法 UTF-8:只取 ASCII U+0020–U+007E | 0.831 |
没有一种能接近 1.0,最好的也被压掉四分之一。原因是结构性的:合法 UTF-8 的字节分布本身就被首字节/续字节的形态约束住了(比如码点均匀取的话,约 94% 落在四字节区,字节流就被 0xF0–0xF4 和 0x80–0xBF 主导),deflate 的 Huffman 一压就下来。
而 large_echo_across_reads 那条注释里写着「载荷压不动,所以这一帧必然跨多次读」——"压不动"是承重的,换掉它这条用例就不再测它本来要测的东西了(只是会静默地变成"还是过",不会报错,这更麻烦)。
所以改 BINARY:载荷本来就是随机二进制,TEXT 是一开始的建模偏差。代价是那条 scenario 的回显链路要补 on_binary + send_binary,还有断言里 f.opcode != ws_opcode::TEXT 那两处要跟着对上。我可以直接在这条 PR 里做掉 —— 这次我会开 zlib 编一份、真的把这四条跑一遍再推,不再靠"本地编不到"糊过去。
如果你觉得那几条的语义上就该是 TEXT(比如想顺带覆盖 TEXT 路径的大消息),那另一条路是把它们拆成两条:一条 BINARY 打跨读、一条 TEXT 用可压缩的合法内容。你说了算。
三、is_server 要不要默认值:我倾向保留必填,但把 bool 换成枚举
先说为什么默认值救不了这个错 —— 两个默认方向都是"静默坏",不是"安全的那一边":
- 默认
true(服务端):客户端侧漏填 → 发出去的是未掩码帧 → 任何合规服务端都会 1002 掉它; - 默认
false(客户端):服务端侧漏填 → 发出去的是带掩码的帧 → 合规客户端会关连接。
也就是说"真搞反了,框架自己的往返用例会抓到"这条只在框架自测自的时候成立 —— 而那恰好就是你列的那条盲区(两侧同错互相掩盖,#9 的掩码问题能藏这么久就是这个原因)。所以默认值把"编译期一定报错"换成了"运行期在别人的实现上才暴露",这个交换我不太想做。
至于你点出的真正代价 ——「没有任何提示」——那是命名的问题,不是默认值的问题。new uvcpp_ws_connection(tcp, true) 确实看不出 true 是什么意思;但换成
enum class ws_role { SERVER, CLIENT };
uvcpp_ws_connection(uvcpp_tcp_client* tcp, ws_role role);调用点就变成 ws_role::SERVER,编不过的时候错误信息也直接说了要什么。必填保住了,提示也有了,两边都占。
这条我可以一并改(四个构造点 + 两个测试文件 + 一个文档注释,机械改动)。当然如果你觉得为这个引入一个枚举不值,那就按你说的给个默认 —— 我只坚持一点:默认值要配一条注释写明"填错方向会静默地让服务端/客户端拒绝你",不然它会变成下一个藏得住的坑。
|
两条都定了,说下理由,然后请你动手。 一、那 4 条:保留 TEXT,负载换成合法 UTF-8你那张 zlib 表我独立复现了一遍(zlib level 6,512 KiB),逐位一样:1.000 / 0.755 / 0.809 / 0.790 / 0.831。所以"让 但「压不动是承重的」这句,只对 4 条里的 1 条成立。我对着用例核了一遍:
所以:4 条都留 TEXT,负载换成一个高熵的合法 UTF-8 生成器。两点要求 ——
并且请你顺手把它从注释变成判据。 现在「载荷压不动,所以这一帧必然跨多次读」只写在 if (f.payload.size() < 256 * 1024) return false; // 线上帧必须大到必然跨多次 read256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量;哪天有人把负载换成 顺带一个副产品:留着 TEXT 之后,这几条会捎带覆盖你新加的 §8.1 校验 —— 带掩码、在负载相位中间被切开的明文 TEXT 帧,正好走一遍"解压之后再校验"的路径。全改 BINARY 就没这段覆盖了。 要是有哪一条你判断非 BINARY 不可,单独说那一条的理由,别整批换。 二、
|
## `bool is_server` → `enum class ws_role`
作者提的代价是"第三方 `new uvcpp_ws_connection(tcp)` 编不过,且没有任何提示"。
采纳其中"要有提示"这一半,但不采纳"给默认值"那一半 —— 理由写在构造函数的
注释里:**两个默认方向都是静默坏**。默认 SERVER 则客户端漏填会发出未掩码帧
(任何合规服务端都会 1002 掉它),默认 CLIENT 则服务端漏填会发出带掩码的帧
(合规客户端会关连接)。也就是说"搞反了框架自己的往返用例会抓到"这条**只在
框架自测自时成立**,而两侧同错恰好是最容易互相掩盖的一种。
真正缺的是"光看 true/false 不知道哪边是哪边",那是命名问题:`ws_role::SERVER`
读起来就没有歧义,编译错误里也会写出 `ws_role`。必填保住了,提示也有了。
## deflate 用例:改用 BINARY
`masked_split_phase_1/2/3` 与 `large_echo_across_reads` 一直在往 **TEXT** 帧里
塞 `incompressible()` 的随机字节 —— 随机字节必然不是合法 UTF-8,撞上 §8.1
校验就以 1007 关连接。
改法选 BINARY 而不是"让 incompressible() 吐合法 UTF-8":后者保不住"压不动"
这条性质,而 `large_echo_across_reads` 的注释写着「载荷压不动,所以这一帧必然
跨多次读」——**"压不动"是承重的**。实测(512 KiB 负载,zlib 压缩后/原始):
LCG 随机字节(现在用的) 1.000
合法 UTF-8:码点均匀取 U+0000–U+10FFFF 0.755
合法 UTF-8:只取 2 字节区 0.809
合法 UTF-8:只取 3 字节区 0.790
合法 UTF-8:只取 ASCII 0.831
没有一种能接近 1.0,原因是结构性的(合法 UTF-8 的字节分布被首字节/续字节的
形态约束住)。换掉它这条用例会**静默地继续通过**,只是不再测它要测的东西。
scenario 里补了 `server_bins` 与 `on_binary`/`send_binary` 的回显链路;两条
用例的 opcode 与断言跟着改成 BINARY,并各留了一段说明,免得以后有人"顺手改
回 TEXT"。
## 验证:这次是真的编了、跑了
之前漏掉 `web_ws_deflate_func.cpp` 的原因是我本地那份构建**没开 zlib**,
那个文件根本没参与编译 —— 这次补上了 zlib 构建(`UVCPP_ENABLE_ZLIB=ON` +
`BUILD_TESTS=ON`),状态是:
ctest: 100% tests passed out of 74
test_web_ws_deflate_func ....... Passed ← 含原来挂的那 4 条
test_web_ws_ownership_func ..... Passed
test_web_ws_reassembly_func .... Passed
其余 71 个 ..................... Passed
顺带一提,这个构建第一次跑就抓到我一个错:回显链路上写的是 `c->on_bin(...)`,
而公开方法是 `on_binary`(`on_bin_` 是成员名)。这种错只有真编一遍才会露头。
|
两条都改完了,这次是真的编了、跑了( 一、
|
| 负载 | 压缩比 |
|---|---|
| LCG 随机字节(现在用的) | 1.000 |
| 合法 UTF-8:码点均匀取 U+0000–U+10FFFF | 0.755 |
| 合法 UTF-8:只取 2 字节区 U+0080–U+07FF | 0.809 |
| 合法 UTF-8:只取 3 字节区 U+0800–U+FFFF | 0.790 |
| 合法 UTF-8:只取 ASCII U+0020–U+007E | 0.831 |
没有一种能接近 1.0,原因是结构性的。而 large_echo_across_reads 的注释里写着「载荷压不动,所以这一帧必然跨多次读」——"压不动"是承重的。换掉它会静默地继续通过,只是不再测它本来要测的东西,这比挂掉更麻烦。
三、验证:ctest 74/74
按你说的,本地补了 zlib 构建(UVCPP_ENABLE_ZLIB=ON + UVCPP_BUILD_TESTS=ON):
100% tests passed out of 74 Total Test time (real) = 95.27 sec
test_web_ws_deflate_func ....... Passed ← 含原来挂的那 4 条
test_web_ws_ownership_func ..... Passed
test_web_ws_reassembly_func .... Passed
顺带说一句,这个构建第一次跑就抓到我另一个错:回显链路上我写的是 c->on_bin(...),而公开方法是 on_binary(on_bin_ 只是成员名)。这种错在"本地编不到"的状态下是永远不会露头的 —— 你上一轮指出的那个问题(我漏掉 web_ws_deflate_func.cpp)根子在这儿,所以我把 zlib 这份构建留着了,后面再动 WS 都拿它验。
CI 那边就拜托再点一次批准了。
|
先说一件事:我们俩撞车了。 我那条定方案(TEXT + 合法 UTF-8 负载)的评论是 所以结论是:按你的 BINARY 来。 我原本反对的理由是"要补约 20 行 harness",你已经把它付掉了,而且这版保住了"压不动"的全力度,比我原来那个"把负载放大到 640 KiB 补偿"的方案干净。这条不用改。 我只差一处上一条里要的、这版没做的: if (f.payload.size() < 256 * 1024) return false; // 线上帧必须大到必然跨多次 read
本机验证:我拉了你的 校验器我验过了
还差两条用例:你新加的两条 MUST,一条判据都没有不是 grep 推的,是本机实测的。我把你这两条检查分别用
关掉一条 check,没有任何用例变红 —— 这就是"这两条 MUST 现在没有判据"的定义。 具体地:
按这个仓库自己的规矩(判据没断言等于没测),麻烦在这条 PR 里补上,三小条就够: A. 服务端收到未掩码帧 → 1002,两条子用例:数据帧一条、控制帧一条。控制帧那条是给你自己那个"放在控制帧/数据帧分叉之前"的位置选择上判据 —— 位置对了才会两个都响,放进去就只能盖住一半。 B. 服务端收到非法 UTF-8 的 TEXT 帧 → 1007,且 C. 同一条,但走压缩路径 —— 用你测试侧的压缩器把非法 UTF-8 压进去、按 rsv1 发出去,断言仍然 1007。这条是给"校验点在解压之后"那个位置选择上的判据;它也正是这件事的起因(放错位置会让所有 deflate 连接都通不过),最值得钉。 B 和 C 建议再加一个"分片"变体:非法字节落在第二个分片里 —— 钉住"校验的是重组后的整条消息,而不是逐帧"。 (形状可以直接抄 顺带CI 上 另外那个 |
`test_large_echo_across_reads` 里那句「载荷压不动,所以这一帧必然跨多次读」
原来只是注释,**没有任何断言**。哪天有人把负载换成可压缩的内容,压缩后的回显
会缩到一次读完,这条用例就**静默地**退化成"不跨读也算过" —— 而它整条存在的
意义就是打跨读那条路径。测试还是绿的,比挂掉难查得多。
加一条:
if (f.payload.size() < 256 * 1024) return false;
256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量。
**验证这条断言是承重的**(不然它只是另一句注释):
负载 = incompressible(512 KiB) -> large_echo_across_reads PASS
负载换成同尺寸可压缩内容(变异) -> large_echo_across_reads FAIL ← 断言响
还原 -> ALL PASS
加断言前后各跑一次全量:`ctest` 74/74。
补这两条检查时没在仓库里留下任何用例 —— 只靠我外部的一个裸 socket 探针验过
一次,将来谁动了 `on_ws_frame` 里的判断顺序,它们会静悄悄地回到老样子,而
CI 一片绿。这正是这次整轮里反复出现的那种形状。
`web_ws_reassembly_func.cpp` 加三条:
unmasked_frame_rejected 未掩码的客户端帧 -> PROTOCOL_ERROR(1002),且不交付
invalid_utf8_rejected 文本帧非法 UTF-8 -> INVALID_PAYLOAD(1007),且不交付
valid_utf8_still_delivered 反向对照:合法多字节 UTF-8 照常交付
第三条是专门防"校验写严了"的:补 UTF-8 校验最容易的错法就是规则过严,于是
中文、emoji 全被拒掉,而一般用例只发 ASCII 根本发现不了。它同时覆盖三字节
(中 U+4E2D)与四字节(😀 U+1F600)两种长度。
顺带一条同批的:`test_large_echo_across_reads` 里「载荷压不动,所以这一帧必然
跨多次读」原来是注释而不是判据,补了
if (f.payload.size() < 256 * 1024) return false;
**两条检查、一个断言,都做过变异验证**(摘掉之后对应用例必须转红):
掩码检查失效 -> unmasked_frame_rejected FAIL
UTF-8 校验失效 -> invalid_utf8_rejected FAIL
valid_utf8_still_delivered PASS(不受影响,符合预期)
负载换成可压缩内容 -> large_echo_across_reads FAIL(新断言响)
全部还原 -> ctest 100% tests passed out of 74
|
撞车的事不用放在心上 —— 39 秒的窗口,我那条回复里确实还在重新论证,一看就是没刷到。按你最后那条来:BINARY 保留。 你两次都点的那条断言补了( 一、把「必然跨多次读」从注释变成判据if (f.payload.size() < 256 * 1024) return false;做了变异验证(不然它只是另一句注释,那正是你反对的形状):
二、顺着你那句"留 TEXT 会捎带覆盖 §8.1"想到的你那条的末尾提了一句:保留 TEXT 的话,那几条会顺带走一遍我新加的 §8.1 校验。改成 BINARY 之后这点覆盖就没了 —— 而我去仓库里翻了一遍,我那两条检查在仓库里一个用例都没有,只靠外部一个裸 socket 探针验过一次。 也就是说:谁哪天动了
第三条是专门防"校验写严了"的 —— 补 UTF-8 校验最容易的错法就是规则过严,于是中文、emoji 全被拒掉,而一般用例只发 ASCII 根本发现不了。它同时覆盖三字节(中 U+4E2D)与四字节(😀 U+1F600)。 这三条也做了变异:
三、调用点清单对上了你给的那 11 条我按 grep 重扫了一遍,一一对上(8 个构造点 + 声明 + 定义 + 文档注释)。上一轮之后我把口径改成全仓 grep 了,这次没漏。 CI 再麻烦点一次批准。 |
|
变异验证分别
我要撤回一条:C(压缩路径)不用写了我上一条让你补的 C ——「压缩帧里塞非法 UTF-8 → 1007」,用来钉「校验点在解压之后」—— 是多余的。上面第三行变异就是它的等价物:真把校验挪到解压前, 是我上一条没先验就开口,抱歉。A 的控制帧变体不算在内,见下。 剩最后一条,有实测证据A 的控制帧变体是真缺口。 我把掩码检查改成只盖数据帧: if (is_server() && !frame.masked && !frame.is_control_frame()) { // 变异 D全套 WS 10/10 全绿 —— 也就是说「有人把检查挪到控制帧/数据帧分叉之后」这个改动,今天没有任何用例会响。 这有价值是因为你当初把检查放在分叉之前是对的,而那个"对"现在只建立在人读上。补一条就够: /** @brief 未掩码的**控制帧**同样违反 §5.1(§5.5 说明控制帧不例外)。 */
static bool test_unmasked_control_frame_rejected() {
scenario s;
if (!s.start()) return false;
if (!s.send(raw_frame(0x9, true, "ping", false))) return false; // PING,mask=false
s.pump_until([&] { return !s.errors.empty(); }, 2000);
return s.errors.size() == 1 &&
s.errors[0] == static_cast<int>(ws_close_code::PROTOCOL_ERROR);
}(这条不要断言 顺带
本机全量:0 error C / 0 error LNK,75/75 通过。 这条补上我就合(合的时候会把 |
IS_RELEASE 保持 0:HTTP/2 全做完之前不升大版本、不出 release。 Co-Authored-By: Claude Code <noreply@anthropic.com>
|
合了。实现本身我上一轮已经变异验证过(三条新用例逐个撞红),这里只交代我为什么不再拿那条用例卡合并,以及我自己的一个错。 我之前那条要求站不住我要的 也就是说:那条用例是回归护栏(防止以后有人把检查挪进数据帧那一支),不是补一个没测到的路径。我把"预防性用例"说成了"判据缺口",这个措辞是错的 —— 而且我是凭推理要的,没有先变异证明缺口是真的。这个错在我这边,不在你的 PR 上。 所以我自己补了
只有它红、数据帧那条照旧绿 —— 这正好证明两条用例钉的是两个不同的位置:一条钉"检查在不在",一条钉"检查放对了没有"。 (顺带一个坑:第一次变异编译失败了( 本机验证
两条 RFC 6455 MUST(§5.1 客户端帧必须掩码、§8.1 文本帧负载必须合法 UTF-8)以及"客户端从不掩码"这个同源缺陷,从这一版起是真的实现了 —— 这个改动本身写得很干净,尤其 UTF-8 校验那条反向对照(合法多字节必须照常交付)是**主动防"规则写严了"**的,一般人不会想到补。 |
修复 issue #8 里的两条 RFC 6455 MUST,外加一条在补它们的过程中暴露出来的对称缺陷。
一、服务端未拒绝未掩码的客户端帧(§5.1)
实测(master,本地从源码构建):
掩码的用途是防中间代理缓存投毒(§10.3),服务端不强制等于把这条防护让掉。
检查放在
on_ws_frame()的最前面 —— 它对所有帧类型都成立,控制帧也不例外(§5.5),放进控制帧/数据帧的分叉之后就只能盖住一半。二、文本消息未校验 UTF-8(§8.1)
校验点在
deliver_message()里解压之后、交给on_text之前 —— 应用看到的是解压后的字节,校验就必须落在同一份字节上。(放在上游验压缩过的数据既无意义,也会让带 permessage-deflate 的连接永远通不过。)校验器覆盖的不只是"形状不对",还有三处语法合法但语义非法的:
0xC0 0x80(用两字节写 U+0000)0xED 0xA0 0x80(U+D800)0xF4 0x90 0x80 0x80只判首字节与续字节的形状会让这三类漏过去 —— 而它们正是"能过形状校验却仍不是合法 UTF-8"的全部来源。
三、补上面两条时暴露出来的:框架自己的客户端也从不掩码
send_data()造帧时从来不设f.masked,所以两侧发的都是未掩码帧。这在服务端那一侧因为没人检查而一直没暴露 —— 一加上第一条检查:框架的客户端发不出一帧自己的服务端愿意收的数据。所以第三条是必须配套的:客户端 MUST 掩码(§5.1 的另一半),而它此前同样没实现。
新增
apply_mask(),在四个发送出口(数据 / PING / PONG / CLOSE)各过一手。没有放进send_frame()里统一做,是因为那个函数的形参是const uvcpp_ws_frame&,要改就得先拷一份 —— 而帧里带着 payload,每帧一次 O(n) 拷贝不值得;四个出口各一行更划算。控制帧也必须盖:§5.5 把 §5.1 的要求一并罩住了。掩码密钥的取法:线程本地的
mt19937_64,种子来自random_device。这不是密码学强度,但对 §5.3 要求的"不可预测"是够的,同时避免了在每帧的发送路径上做 4 次系统调用(逐帧调random_device在 Linux 上就是每帧 4 次读/dev/urandom)。这一点在代码注释里写明了,如果你更倾向逐帧取强随机,改一行即可。uvcpp_ws_connection的构造函数补上bool is_server,必填、无默认值 —— 掩码的方向性要求两侧是反的,搞反了任意一侧都直接不可用,所以让它必须被写出来而不是靠默认值猜。四个构造点(server / client ×2 / 两个测试文件)都已更新。验证
仓库自带的 7 个 WS 测试全部通过(本地构建,
-DUVCPP_BUILD_TESTS=ON):其中
ws_func的 echo 用例通过,本身就证明客户端确实在掩码了 —— 服务端现在要求掩码,客户端不掩的话这条必然失败。这条是框架客户端 ↔ 框架服务端的往返,所以它同时锁住了两侧。外部探针(独立写的裸 socket 客户端):
第二条那一组是专门用来钉住反向风险的 —— 补 UTF-8 校验最容易的错法就是规则写严了,于是中文和 emoji 全被 1007 拒掉,而一般测试只发 ASCII,根本发现不了。
过程中踩到并如实记一笔的两件事
第一版只加了服务端的两条检查就提交,把仓库自己的两个 WS 用例打挂了。 是靠"把源码还原成未修改版、重编、再跑一遍"拿到基线才确认是自己的问题 —— 否则很容易当成"本地构建配置的差异"放过去。(顺带一提:那一轮"全过"的测试结果其实是假的 —— 库因为语法错误没编出来,跑的是上一次的基线 DLL。看 DLL 时间戳才发现。)
改
uvcpp_ws_sessions.h里的文档注释时写了new uvcpp_ws_connection(tcp, /*is_server=*/true),那个*/把/** ... */提前闭合了,整份头文件语法错误。文档注释里不能出现/*...*/形式的行内注释 —— 记在这里免得别人再踩。