From 56ff446161ca2ba7d5abded9e5ec763451d6f968 Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:55:06 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(ws):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=8E=A9=E7=A0=81?= =?UTF-8?q?=E3=80=81=E6=96=87=E6=9C=AC=E6=B6=88=E6=81=AF=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=20UTF-8=EF=BC=8C=E5=B9=B6=E8=AE=A9=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E7=9C=9F=E7=9A=84=E6=8E=A9=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条 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)`,那个 `*/` **把 `/** ... */` 提前闭合了**,整份头文件 语法错误。文档注释里不能出现 `/*...*/` 形式的行内注释。 --- src/web/uvcpp_ws_client.cpp | 4 +- src/web/uvcpp_ws_connection.cpp | 110 +++++++++++++++++++- src/web/uvcpp_ws_connection.h | 23 +++- src/web/uvcpp_ws_server.cpp | 2 +- src/web/uvcpp_ws_sessions.h | 2 +- tests/functional/web_ws_ownership_func.cpp | 6 +- tests/functional/web_ws_reassembly_func.cpp | 2 +- 7 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/web/uvcpp_ws_client.cpp b/src/web/uvcpp_ws_client.cpp index 0dfa956e..620ec6e9 100644 --- a/src/web/uvcpp_ws_client.cpp +++ b/src/web/uvcpp_ws_client.cpp @@ -361,7 +361,7 @@ void uvcpp_ws_client::on_handshake_complete(int error) { // 握手完成之前调用方就 `close()` 了(取消那次已经在 `close()` 里结算 // 过,这里只是运输层还来不及被拆掉)。这次连接**不算成立**:会话照旧 // 接管(生命周期归 `sessions_`),但立刻关掉,回调不叫第二次。 - auto* cancelled = new uvcpp_ws_connection(tcp_); + auto* cancelled = new uvcpp_ws_connection(tcp_, /*is_server=*/false); sessions_.adopt(cancelled); cancelled->close(ws_close_code::NORMAL); return; @@ -371,7 +371,7 @@ void uvcpp_ws_client::on_handshake_complete(int error) { // 句柄是有意的:`uv_async_init` 必须在循环线程上做,而 `connect()` 可能 // 是从别的线程调进来的(那时候句柄还没法建)。 sessions_.set_loop(loop_); - auto* conn = new uvcpp_ws_connection(tcp_); + auto* conn = new uvcpp_ws_connection(tcp_, /*is_server=*/false); // **先接管所有权再 start()**:反过来的话,对端若在我们 start() 的过程中 // 就断了(关闭观察者立刻回调),会话会不知道把自己交给谁。 sessions_.adopt(conn); diff --git a/src/web/uvcpp_ws_connection.cpp b/src/web/uvcpp_ws_connection.cpp index c8edc37f..a55b5b5b 100644 --- a/src/web/uvcpp_ws_connection.cpp +++ b/src/web/uvcpp_ws_connection.cpp @@ -1,10 +1,13 @@ #include + +#include #if UVCPP_WEB_ENABLE #include namespace uvcpp { -uvcpp_ws_connection::uvcpp_ws_connection(uvcpp_tcp_client* tcp) : tcp_(tcp) { +uvcpp_ws_connection::uvcpp_ws_connection(uvcpp_tcp_client* tcp, bool is_server) + : tcp_(tcp), is_server_(is_server) { // 存活令牌(见头文件):写完成回调用它判断"会话还在不在"。 alive_token_ = std::shared_ptr(new char); parser_.set_on_frame([this](const uvcpp_ws_frame& f) { on_ws_frame(f); }); @@ -167,6 +170,26 @@ void uvcpp_ws_connection::on_tcp_data(uvcpp_buf* buf) { void uvcpp_ws_connection::on_ws_frame(const uvcpp_ws_frame& frame) { if (protocol_failed_) return; + // --------------------------------------------------------------------- + // 掩码的方向性要求(RFC 6455 §5.1)。 + // + // 这一条对**所有帧**都成立,控制帧也不例外(§5.5),所以放在控制帧/数据帧 + // 分叉之前 —— 放进去就只能盖住一半。 + // + // 规范原文是两侧各一条 MUST:"The server MUST close the connection upon + // receiving a frame that is not masked" 与客户端侧的镜像要求;两个方向都用 + // 1002。掩码本来的用途是防中间代理缓存投毒(§10.3),服务端不强制等于把 + // 那条防护让掉。 + // --------------------------------------------------------------------- + if (is_server_ && !frame.masked) { + protocol_error(ws_close_code::PROTOCOL_ERROR, "client frame must be masked"); + return; + } + if (!is_server_ && frame.masked) { + protocol_error(ws_close_code::PROTOCOL_ERROR, "server frame must not be masked"); + return; + } + // --------------------------------------------------------------------- // 控制帧:可以穿插在分片消息中间,但**不参与重组**,也不影响 in_message_ // (RFC 6455 §5.4)。控制帧自身不得分片、负载不得 >125 字节 —— 这两条已经 @@ -259,6 +282,52 @@ void uvcpp_ws_connection::on_ws_frame(const uvcpp_ws_frame& frame) { deliver_message(); } +namespace { + +/** + * @brief UTF-8 合法性校验(RFC 3629)。 + * + * RFC 6455 §8.1:文本帧的负载必须是合法 UTF-8,不是就 MUST Fail the + * WebSocket Connection。库此前**一处校验都没有** —— 非法字节会被原样交给 + * `on_text`,一个把它转给浏览器的应用(聊天室这类)会在客户端那边才炸。 + * + * 覆盖的不只是"形状不对",还有三处**语法合法但语义非法**的: + * - overlong 编码(`0xC0 0x80` 这种"用两个字节写 ASCII"); + * - UTF-16 代理区 U+D800–U+DFFF(CESU-8 那种编码不该出现在 UTF-8 里); + * - 超出 U+10FFFF 的范围(`0xF4 0x90` 以上)。 + * 只判首字节与续字节的形状会让这三类漏过去 —— 而它们正是"能过校验却仍不是 + * 合法 UTF-8"的全部来源。 + */ +bool ws_is_valid_utf8(const uint8_t* p, size_t n) { + size_t i = 0; + while (i < n) { + const uint8_t c = p[i]; + if (c <= 0x7F) { ++i; continue; } // ASCII + + size_t need; + if (c >= 0xC2 && c <= 0xDF) need = 1; // 2 字节(0xC0/0xC1 是 overlong) + else if (c >= 0xE0 && c <= 0xEF) need = 2; // 3 字节 + else if (c >= 0xF0 && c <= 0xF4) need = 3; // 4 字节(>0xF4 超出 U+10FFFF) + else return false; // 孤立续字节 / 非法首字节 + + if (n - i < need + 1) return false; // 尾部不够 + for (size_t k = 1; k <= need; ++k) { + if ((p[i + k] & 0xC0) != 0x80) return false; // 续字节必须是 10xxxxxx + } + if (need == 2) { + if (c == 0xE0 && p[i + 1] < 0xA0) return false; // overlong + if (c == 0xED && p[i + 1] > 0x9F) return false; // 代理区 + } else if (need == 3) { + if (c == 0xF0 && p[i + 1] < 0x90) return false; // overlong + if (c == 0xF4 && p[i + 1] > 0x8F) return false; // > U+10FFFF + } + i += need + 1; + } + return true; +} + +} // namespace + void uvcpp_ws_connection::deliver_message() { const ws_opcode op = message_opcode_; const bool compressed = message_compressed_; @@ -286,6 +355,19 @@ void uvcpp_ws_connection::deliver_message() { #endif if (op == ws_opcode::TEXT) { + // RFC 6455 §8.1:文本消息必须是合法 UTF-8,否则以 1007 失败。 + // + // **位置在解压之后**:应用看到的是解压后的字节,校验就必须落在同一份字节 + // 上 —— 在上游验压缩过的数据既无意义(那是二进制),也会让带 permessage- + // deflate 的连接永远通不过。 + // + // `data` 在空消息时可能是 nullptr,校验器对 (nullptr, 0) 返回 true。 + if (!ws_is_valid_utf8(data, len)) { + message_payload_.clear(); + protocol_error(ws_close_code::INVALID_PAYLOAD, + "text message is not valid UTF-8"); + return; + } // 空消息时 data 可能是 nullptr,`std::string(nullptr, 0)` 是 UB,单独走一条。 if (on_text_) { on_text_(len ? std::string(reinterpret_cast(data), len) : std::string()); @@ -425,6 +507,7 @@ int uvcpp_ws_connection::send_data(ws_opcode op, const char* d, size_t n, f.payload.clone_data(d, n); #endif } + apply_mask(f); return send_frame(f, cb); } @@ -452,15 +535,39 @@ void uvcpp_ws_connection::set_compress_min_size(size_t n) { compress_min_size_ = size_t uvcpp_ws_connection::get_compress_min_size() const { return compress_min_size_; } #endif +void uvcpp_ws_connection::apply_mask(uvcpp_ws_frame& f) const { + if (is_server_) { + // 服务端发的帧**必须不掩码**(§5.1)。显式清掉而不是不管:帧是调用方 + // 造的,万一哪天有谁填了 masked,服务端发出去就成了协议错误。 + f.masked = false; + return; + } + f.masked = true; + // 掩码密钥要"来自强熵源"(§5.3)—— 它防的是中间代理按**可预测**的字节模式 + // 缓存投毒,用 rand()/时间戳这类源等于把这条防护让掉。 + // + // 实现取「线程本地的 mt19937_64,种子来自 random_device」:一次种子来自强熵源, + // 之后由 PRNG 展开。**这不是密码学强度**,但对"不可预测"这个要求是够的 + // (每帧的密钥不会被同一连接内的前序字节推出来),而且避免了在每帧的发送路径上 + // 做 4 次系统调用 —— 逐帧调 random_device 在 Linux 上就是每帧 4 次读 /dev/urandom。 + static thread_local std::mt19937_64 rng(std::random_device{}()); + const uint64_t r = rng(); + for (int i = 0; i < 4; ++i) { + f.mask_key[i] = static_cast((r >> (i * 8)) & 0xFF); + } +} + int uvcpp_ws_connection::send_ping(const char* d, size_t n) { uvcpp_ws_frame f; f.opcode = ws_opcode::PING; if (d && n > 0) f.payload.clone_data(d, n); + apply_mask(f); return send_frame(f, nullptr); } int uvcpp_ws_connection::send_pong(const char* d, size_t n) { uvcpp_ws_frame f; f.opcode = ws_opcode::PONG; if (d && n > 0) f.payload.clone_data(d, n); + apply_mask(f); return send_frame(f, nullptr); } @@ -469,6 +576,7 @@ int uvcpp_ws_connection::send_close(ws_close_code cd, const std::string& rs) { // 控制帧负载上限 125 字节(RFC 6455 §5.5);扣掉 2 字节状态码,原因最多 123。 // 超了会拼出一个对端**必须拒绝**的帧,所以在这里截断,而不是让它发出去。 f.set_close_payload(cd, rs.size() > 123 ? rs.substr(0, 123) : rs); + apply_mask(f); return send_frame(f, [this](int) { // 走 client->close():直接关句柄会绕过框架的关闭回调,服务端的客户端对象 // 会被漏在登记表里。关完之后不能再用 tcp_(框架可能已经把它 delete 掉)。 diff --git a/src/web/uvcpp_ws_connection.h b/src/web/uvcpp_ws_connection.h index 35c48829..42d159bc 100644 --- a/src/web/uvcpp_ws_connection.h +++ b/src/web/uvcpp_ws_connection.h @@ -47,8 +47,15 @@ class UVCPP_API uvcpp_ws_connection { UVCPP_DEFINE_FUNC(uvcpp_ws_connection) UVCPP_DEFINE_COPY_FUNC_DELETE(uvcpp_ws_connection) - /** @brief Wrap an already-upgraded TCP client. Call start() to begin. */ - explicit uvcpp_ws_connection(uvcpp_tcp_client* tcp); + /** + * @brief Wrap an already-upgraded TCP client. Call start() to begin. + * + * @param is_server 本端角色。**必填、无默认值** —— RFC 6455 对两侧的帧要求是 + * **不对称**的:客户端发来的帧 MUST 带掩码(§5.1),服务端发来的 + * MUST NOT(同节)。搞反了任意一侧都会直接不可用,所以让它必须被 + * 写出来,而不是靠一个"猜多半是服务端"的默认值。 + */ + uvcpp_ws_connection(uvcpp_tcp_client* tcp, bool is_server); /** * @brief 开始读 WS 帧。握手完成后调**一次**,且调用方已经不在 TCP 读回调里。 @@ -233,6 +240,15 @@ class UVCPP_API uvcpp_ws_connection { /** @return 0 已入队/已发出;非 0 表示这一帧没能发出去(错误码)。 */ int send_frame(const uvcpp_ws_frame& frame, std::function cb); + /** + * @brief 按本端角色给帧盖上掩码(RFC 6455 §5.1:客户端发出的帧 MUST 掩码、 + * 服务端发出的 MUST NOT)。 + * + * **四个发送出口都要过这一手** —— 数据帧、PING、PONG、CLOSE。控制帧同样是 + * "本端发出的帧",§5.5 把 §5.1 的要求一并罩住了,只盖数据帧等于漏一半。 + */ + void apply_mask(uvcpp_ws_frame& f) const; + // --- 发送队列 --- // `uvcpp_tcp_client::write()` 同一时刻只允许一个异步写:已有在途写时它直接 // 返回 UV_EALREADY,**并且在返回之前不保存回调**。原先的 send_frame 对非 0 @@ -253,6 +269,9 @@ class UVCPP_API uvcpp_ws_connection { void protocol_error(ws_close_code code, const std::string& reason); uvcpp_tcp_client* tcp_ = nullptr; + + /// 本端角色。决定掩码检查的方向(见构造函数的说明)。 + bool is_server_ = true; uvcpp_ws_parser parser_; bool started_ = false; diff --git a/src/web/uvcpp_ws_server.cpp b/src/web/uvcpp_ws_server.cpp index ade685ee..e6d9e402 100644 --- a/src/web/uvcpp_ws_server.cpp +++ b/src/web/uvcpp_ws_server.cpp @@ -221,7 +221,7 @@ void uvcpp_ws_server::handle_upgrade( sessions_.set_loop(http_server_->get_tcp_server()->get_loop()); } - auto* conn = new uvcpp_ws_connection(client); + auto* conn = new uvcpp_ws_connection(client, /*is_server=*/true); // **先接管所有权,再 start()。** 反过来的话,如果对端在我们 start() 的 // 过程中就断了(关闭观察者立刻回调),会话会不知道把自己交给谁。 sessions_.adopt(conn); diff --git a/src/web/uvcpp_ws_sessions.h b/src/web/uvcpp_ws_sessions.h index 14435e69..25b82447 100644 --- a/src/web/uvcpp_ws_sessions.h +++ b/src/web/uvcpp_ws_sessions.h @@ -53,7 +53,7 @@ class uvcpp_async; * * 典型用法(服务器、客户端都一样): * @code - * uvcpp_ws_connection* c = new uvcpp_ws_connection(tcp); + * uvcpp_ws_connection* c = new uvcpp_ws_connection(tcp, true); // is_server * sessions_.adopt(c); // 接管所有权 + 装终结回调 * c->start(); * @endcode diff --git a/tests/functional/web_ws_ownership_func.cpp b/tests/functional/web_ws_ownership_func.cpp index c1d7ccae..78148386 100644 --- a/tests/functional/web_ws_ownership_func.cpp +++ b/tests/functional/web_ws_ownership_func.cpp @@ -134,7 +134,7 @@ struct scenario { sessions.set_loop(server.get_loop()); int rc = server.listen([this](uvcpp_tcp_client* c) { - auto* wc = new uvcpp_ws_connection(c); + auto* wc = new uvcpp_ws_connection(c, /*is_server=*/true); wc->on_close([this](ws_close_code cd, const std::string&) { closes.push_back(static_cast(cd)); }); @@ -376,7 +376,7 @@ static void t_client_side_inflight_then_close() { if (!(connected && accepted != nullptr)) { check(false, "connect"); return; } // 客户端侧的会话 —— 它的写完成闭包由 `cli` 持有,而 `cli` 比会话活得久。 - auto* wc = new uvcpp_ws_connection(&cli); + auto* wc = new uvcpp_ws_connection(&cli, /*is_server=*/false); sessions.adopt(wc); wc->start(); @@ -438,7 +438,7 @@ static void t_owner_terminate_with_inflight_write() { uvcpp_test::kWaitMs); if (!(connected && accepted != nullptr)) { check(false, "connect"); return; } - auto* wc = new uvcpp_ws_connection(&cli); + auto* wc = new uvcpp_ws_connection(&cli, /*is_server=*/false); sessions.adopt(wc); wc->start(); diff --git a/tests/functional/web_ws_reassembly_func.cpp b/tests/functional/web_ws_reassembly_func.cpp index 14f32c31..ba4f82b7 100644 --- a/tests/functional/web_ws_reassembly_func.cpp +++ b/tests/functional/web_ws_reassembly_func.cpp @@ -152,7 +152,7 @@ struct scenario { // `set_loop` 必须在循环线程、循环正跑着的时候调(`uv_async_init` 不是 // 线程安全的),而这里正是接入回调,两个条件都满足。 sessions_.set_loop(server.get_loop()); - auto* wc = new uvcpp_ws_connection(c); + auto* wc = new uvcpp_ws_connection(c, /*is_server=*/true); sessions_.adopt(wc); if (max_msg > 0) wc->set_max_message_size(max_msg); wc->on_text([this](const std::string& m) { texts.push_back(m); }); From c71ed418beded59e640af30faefb5a458e8f62df Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:56:11 +0800 Subject: [PATCH 2/5] =?UTF-8?q?test(ws):=20=E8=A1=A5=E4=B8=8A=E6=BC=8F?= =?UTF-8?q?=E6=8E=89=E7=9A=84=E7=AC=AC=E4=B8=89=E4=B8=AA=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E7=82=B9=EF=BC=88web=5Fws=5Fdeflate=5Ffunc.cpp=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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:这条用例只是"往一个死连接发帧、断言失败被上报",不涉及真实 对端,两种角色都能过;按"独立连接主动发帧"的样子当成客户端更自然。 --- tests/functional/web_ws_deflate_func.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/web_ws_deflate_func.cpp b/tests/functional/web_ws_deflate_func.cpp index 2fcf3d4e..6a42e70d 100644 --- a/tests/functional/web_ws_deflate_func.cpp +++ b/tests/functional/web_ws_deflate_func.cpp @@ -955,7 +955,7 @@ static bool test_send_failure_is_reported() { // 必须先析构(逆序),`dead` 后析构。反过来的话 `~uvcpp_ws_connection` 会 // 去碰一个已经释放的循环 —— 与第十三批修掉的形状同源。 uvcpp_tcp_client dead; - uvcpp_ws_connection conn(&dead); + uvcpp_ws_connection conn(&dead, /*is_server=*/false); // 首帧:入队后立刻在 pump 里失败。错误走**回调**(`send_frame` 的返回值 // 是"有没有排上队",不是"有没有发出去"),所以这里断言回调。 From 1b10d50edc10715f82e88d8fd5e09972230e96b2 Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:02:57 +0800 Subject: [PATCH 3/5] =?UTF-8?q?test(ws):=20=E6=8A=8A=20is=5Fserver=20?= =?UTF-8?q?=E6=8D=A2=E6=88=90=20ws=5Frole=20=E6=9E=9A=E4=B8=BE=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=8E=89=20deflate=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E9=9D=9E=E6=B3=95=20UTF-8=20=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## `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_` 是成员名)。这种错只有真编一遍才会露头。 --- src/web/uvcpp_ws_client.cpp | 4 +-- src/web/uvcpp_ws_connection.cpp | 10 +++--- src/web/uvcpp_ws_connection.h | 31 +++++++++++++++--- src/web/uvcpp_ws_server.cpp | 2 +- src/web/uvcpp_ws_sessions.h | 2 +- tests/functional/web_ws_deflate_func.cpp | 36 +++++++++++++++------ tests/functional/web_ws_ownership_func.cpp | 6 ++-- tests/functional/web_ws_reassembly_func.cpp | 2 +- 8 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/web/uvcpp_ws_client.cpp b/src/web/uvcpp_ws_client.cpp index 620ec6e9..ffe7a800 100644 --- a/src/web/uvcpp_ws_client.cpp +++ b/src/web/uvcpp_ws_client.cpp @@ -361,7 +361,7 @@ void uvcpp_ws_client::on_handshake_complete(int error) { // 握手完成之前调用方就 `close()` 了(取消那次已经在 `close()` 里结算 // 过,这里只是运输层还来不及被拆掉)。这次连接**不算成立**:会话照旧 // 接管(生命周期归 `sessions_`),但立刻关掉,回调不叫第二次。 - auto* cancelled = new uvcpp_ws_connection(tcp_, /*is_server=*/false); + auto* cancelled = new uvcpp_ws_connection(tcp_, ws_role::CLIENT); sessions_.adopt(cancelled); cancelled->close(ws_close_code::NORMAL); return; @@ -371,7 +371,7 @@ void uvcpp_ws_client::on_handshake_complete(int error) { // 句柄是有意的:`uv_async_init` 必须在循环线程上做,而 `connect()` 可能 // 是从别的线程调进来的(那时候句柄还没法建)。 sessions_.set_loop(loop_); - auto* conn = new uvcpp_ws_connection(tcp_, /*is_server=*/false); + auto* conn = new uvcpp_ws_connection(tcp_, ws_role::CLIENT); // **先接管所有权再 start()**:反过来的话,对端若在我们 start() 的过程中 // 就断了(关闭观察者立刻回调),会话会不知道把自己交给谁。 sessions_.adopt(conn); diff --git a/src/web/uvcpp_ws_connection.cpp b/src/web/uvcpp_ws_connection.cpp index a55b5b5b..971d6f71 100644 --- a/src/web/uvcpp_ws_connection.cpp +++ b/src/web/uvcpp_ws_connection.cpp @@ -6,8 +6,8 @@ namespace uvcpp { -uvcpp_ws_connection::uvcpp_ws_connection(uvcpp_tcp_client* tcp, bool is_server) - : tcp_(tcp), is_server_(is_server) { +uvcpp_ws_connection::uvcpp_ws_connection(uvcpp_tcp_client* tcp, ws_role role) + : tcp_(tcp), role_(role) { // 存活令牌(见头文件):写完成回调用它判断"会话还在不在"。 alive_token_ = std::shared_ptr(new char); parser_.set_on_frame([this](const uvcpp_ws_frame& f) { on_ws_frame(f); }); @@ -181,11 +181,11 @@ void uvcpp_ws_connection::on_ws_frame(const uvcpp_ws_frame& frame) { // 1002。掩码本来的用途是防中间代理缓存投毒(§10.3),服务端不强制等于把 // 那条防护让掉。 // --------------------------------------------------------------------- - if (is_server_ && !frame.masked) { + if (is_server() && !frame.masked) { protocol_error(ws_close_code::PROTOCOL_ERROR, "client frame must be masked"); return; } - if (!is_server_ && frame.masked) { + if (!is_server() && frame.masked) { protocol_error(ws_close_code::PROTOCOL_ERROR, "server frame must not be masked"); return; } @@ -536,7 +536,7 @@ size_t uvcpp_ws_connection::get_compress_min_size() const { return compress_min_ #endif void uvcpp_ws_connection::apply_mask(uvcpp_ws_frame& f) const { - if (is_server_) { + if (is_server()) { // 服务端发的帧**必须不掩码**(§5.1)。显式清掉而不是不管:帧是调用方 // 造的,万一哪天有谁填了 masked,服务端发出去就成了协议错误。 f.masked = false; diff --git a/src/web/uvcpp_ws_connection.h b/src/web/uvcpp_ws_connection.h index 42d159bc..ac5283db 100644 --- a/src/web/uvcpp_ws_connection.h +++ b/src/web/uvcpp_ws_connection.h @@ -42,6 +42,20 @@ namespace uvcpp { +/** + * @brief 本端在一条 WebSocket 连接上的角色。 + * + * **为什么要一个枚举而不是 `bool is_server`。** RFC 6455 对两侧的帧要求是 + * **不对称**的:客户端发出的帧 MUST 掩码、服务端发出的 MUST NOT(§5.1)。 + * 既然方向搞反了会直接让连接不可用,这个参数就不该有默认值(见构造函数的 + * 说明);而"必填"之后,剩下的问题是**光看 `true`/`false` 不知道哪边是哪边** —— + * 枚举正好补上这一点,而且报错信息里也会直接写出 `ws_role`。 + */ +enum class ws_role : int { + SERVER = 0, ///< 本端是服务端:发出去的帧**不**掩码,收到的帧**必须**掩码 + CLIENT = 1 ///< 本端是客户端:发出去的帧**必须**掩码,收到的帧**不**该掩码 +}; + class UVCPP_API uvcpp_ws_connection { public: UVCPP_DEFINE_FUNC(uvcpp_ws_connection) @@ -50,12 +64,18 @@ class UVCPP_API uvcpp_ws_connection { /** * @brief Wrap an already-upgraded TCP client. Call start() to begin. * - * @param is_server 本端角色。**必填、无默认值** —— RFC 6455 对两侧的帧要求是 - * **不对称**的:客户端发来的帧 MUST 带掩码(§5.1),服务端发来的 + * @param role 本端角色。**必填、无默认值** —— RFC 6455 对两侧的帧要求是 + * **不对称**的:客户端发出的帧 MUST 掩码(§5.1),服务端发出的 * MUST NOT(同节)。搞反了任意一侧都会直接不可用,所以让它必须被 * 写出来,而不是靠一个"猜多半是服务端"的默认值。 + * + * **也别给它加默认值来"省一次编译错误"。** 两个默认方向都是静默坏: + * 默认 SERVER 则客户端漏填会发出未掩码帧(任何合规服务端都会 1002 + * 掉它),默认 CLIENT 则服务端漏填会发出带掩码的帧(合规客户端会关 + * 连接)。换句话说,"搞反了框架自己的往返用例会抓到"这条**只在 + * 框架自测自时成立**,而两侧同错恰好是最容易互相掩盖的一种。 */ - uvcpp_ws_connection(uvcpp_tcp_client* tcp, bool is_server); + uvcpp_ws_connection(uvcpp_tcp_client* tcp, ws_role role); /** * @brief 开始读 WS 帧。握手完成后调**一次**,且调用方已经不在 TCP 读回调里。 @@ -271,7 +291,10 @@ class UVCPP_API uvcpp_ws_connection { uvcpp_tcp_client* tcp_ = nullptr; /// 本端角色。决定掩码检查的方向(见构造函数的说明)。 - bool is_server_ = true; + ws_role role_ = ws_role::SERVER; + + /// 只在本类内部用;语义同 `role_ == ws_role::SERVER`。 + bool is_server() const { return role_ == ws_role::SERVER; } uvcpp_ws_parser parser_; bool started_ = false; diff --git a/src/web/uvcpp_ws_server.cpp b/src/web/uvcpp_ws_server.cpp index e6d9e402..48e8c5e6 100644 --- a/src/web/uvcpp_ws_server.cpp +++ b/src/web/uvcpp_ws_server.cpp @@ -221,7 +221,7 @@ void uvcpp_ws_server::handle_upgrade( sessions_.set_loop(http_server_->get_tcp_server()->get_loop()); } - auto* conn = new uvcpp_ws_connection(client, /*is_server=*/true); + auto* conn = new uvcpp_ws_connection(client, ws_role::SERVER); // **先接管所有权,再 start()。** 反过来的话,如果对端在我们 start() 的 // 过程中就断了(关闭观察者立刻回调),会话会不知道把自己交给谁。 sessions_.adopt(conn); diff --git a/src/web/uvcpp_ws_sessions.h b/src/web/uvcpp_ws_sessions.h index 25b82447..636882fe 100644 --- a/src/web/uvcpp_ws_sessions.h +++ b/src/web/uvcpp_ws_sessions.h @@ -53,7 +53,7 @@ class uvcpp_async; * * 典型用法(服务器、客户端都一样): * @code - * uvcpp_ws_connection* c = new uvcpp_ws_connection(tcp, true); // is_server + * uvcpp_ws_connection* c = new uvcpp_ws_connection(tcp, ws_role::SERVER); * sessions_.adopt(c); // 接管所有权 + 装终结回调 * c->start(); * @endcode diff --git a/tests/functional/web_ws_deflate_func.cpp b/tests/functional/web_ws_deflate_func.cpp index 6a42e70d..c5f43350 100644 --- a/tests/functional/web_ws_deflate_func.cpp +++ b/tests/functional/web_ws_deflate_func.cpp @@ -180,6 +180,8 @@ struct scenario { std::string handshake_resp; // 101 的原始报文 std::vector server_texts; + /// 收到的 BINARY 消息。与 server_texts 分开存,判据才分得清"回没回错类型"。 + std::vector server_bins; std::vector cframes; // 服务端 → 客户端方向,测试侧解析 bool parse_error = false; @@ -220,6 +222,12 @@ struct scenario { server_texts.push_back(m); if (conn) conn->send_text(m.c_str(), m.size()); }); + // 二进制回显:**原样回 binary**。回成 text 的话,随机字节的二进制消息 + // 根本回不过去(它不是合法 UTF-8),而"回显一致"这条判据也就失去意义了。 + c->on_binary([this](const uint8_t* d, size_t n) { + server_bins.push_back(std::string(reinterpret_cast(d), n)); + if (conn) conn->send_binary(reinterpret_cast(d), n); + }); }); if (server->listen() != 0) { why = "listen"; return false; } @@ -491,7 +499,11 @@ static bool masked_split_phase(size_t payload_offset_in_segment) { if (!upgraded(s, "permessage-deflate; client_max_window_bits")) return false; const std::string msg = incompressible(256 * 1024); - const std::string frame = raw_frame(0x1, true, msg, true, 0x00); + // **发 BINARY 而不是 TEXT**:`incompressible()` 吐的是随机字节,**必然不是 + // 合法 UTF-8**,而 TEXT 帧按 RFC 6455 §8.1 必须校验 UTF-8(不合法就以 1007 + // 关连接)。这条用例要的是"载荷压不动、帧跨多次读",与"它是不是文本"无关, + // 所以按它本来的样子(二进制)发才对。改回 TEXT 会让这条用例挂掉。 + const std::string frame = raw_frame(0x2, true, msg, true, 0x00); // 负载 > 65535 → 帧头是 2 + 8(长度) + 4(掩码) = 14 字节。 const size_t header = 2 + 8 + 4; if (frame.size() <= header + 4) return false; @@ -500,14 +512,14 @@ static bool masked_split_phase(size_t payload_offset_in_segment) { if (!s.send_bytes(frame.substr(0, cut))) return false; s.settle(50); if (s.parse_error) return false; - if (!s.server_texts.empty()) return false; // 半帧不算消息 + if (!s.server_bins.empty()) return false; // 半帧不算消息 if (!s.send_bytes(frame.substr(cut))) return false; - if (!s.pump([&] { return !s.server_texts.empty(); }, 10000)) return false; + if (!s.pump([&] { return !s.server_bins.empty(); }, 10000)) return false; if (s.parse_error) return false; - if (s.server_texts.size() != 1) return false; + if (s.server_bins.size() != 1) return false; // 相位错一位,解出来的就是一堆乱码 —— 这个比较就是判据 - return s.server_texts[0] == msg; + return s.server_bins[0] == msg; } /** @brief 把上面那条用例绑成 `bool(*)()`(用例表用的是裸函数指针,C++11 没有 @@ -535,9 +547,13 @@ static bool test_large_echo_across_reads() { if (!s.setup_compression(uvcpp_ws_deflate_config())) return false; const std::string msg = incompressible(512 * 1024); - if (!s.send_bytes(raw_frame(0x1, true, msg, true, 0x00))) return false; - if (!s.pump([&] { return !s.server_texts.empty(); }, 10000)) return false; - if (s.server_texts.size() != 1 || s.server_texts[0] != msg) return false; + // **发 BINARY 而不是 TEXT**:`incompressible()` 吐的是随机字节,**必然不是 + // 合法 UTF-8**,而 TEXT 帧按 RFC 6455 §8.1 必须校验 UTF-8(不合法就以 1007 + // 关连接)。这条用例要的是"载荷压不动、帧跨多次读",与"它是不是文本"无关, + // 所以按它本来的样子(二进制)发才对。改回 TEXT 会让这条用例挂掉。 + if (!s.send_bytes(raw_frame(0x2, true, msg, true, 0x00))) return false; + if (!s.pump([&] { return !s.server_bins.empty(); }, 10000)) return false; + if (s.server_bins.size() != 1 || s.server_bins[0] != msg) return false; // 等回显整帧解析完。载荷压不动,所以这一帧必然跨多次读。 if (!s.pump([&] { return !s.cframes.empty(); }, 10000)) return false; @@ -545,7 +561,7 @@ static bool test_large_echo_across_reads() { if (s.cframes.size() != 1) return false; // 跨读的帧只能算**一条** const uvcpp_ws_frame& f = s.cframes[0]; - if (f.opcode != ws_opcode::TEXT) return false; + if (f.opcode != ws_opcode::BINARY) return false; if (!f.rsv1) return false; // 别拿压缩后的长度和明文比:`incompressible` 的数据 deflate 之后**更大** // (每 16KB 一个存储块,加 5 字节块头:512KiB 的负载量出来是 524449)。 @@ -955,7 +971,7 @@ static bool test_send_failure_is_reported() { // 必须先析构(逆序),`dead` 后析构。反过来的话 `~uvcpp_ws_connection` 会 // 去碰一个已经释放的循环 —— 与第十三批修掉的形状同源。 uvcpp_tcp_client dead; - uvcpp_ws_connection conn(&dead, /*is_server=*/false); + uvcpp_ws_connection conn(&dead, ws_role::CLIENT); // 首帧:入队后立刻在 pump 里失败。错误走**回调**(`send_frame` 的返回值 // 是"有没有排上队",不是"有没有发出去"),所以这里断言回调。 diff --git a/tests/functional/web_ws_ownership_func.cpp b/tests/functional/web_ws_ownership_func.cpp index 78148386..92f1293d 100644 --- a/tests/functional/web_ws_ownership_func.cpp +++ b/tests/functional/web_ws_ownership_func.cpp @@ -134,7 +134,7 @@ struct scenario { sessions.set_loop(server.get_loop()); int rc = server.listen([this](uvcpp_tcp_client* c) { - auto* wc = new uvcpp_ws_connection(c, /*is_server=*/true); + auto* wc = new uvcpp_ws_connection(c, ws_role::SERVER); wc->on_close([this](ws_close_code cd, const std::string&) { closes.push_back(static_cast(cd)); }); @@ -376,7 +376,7 @@ static void t_client_side_inflight_then_close() { if (!(connected && accepted != nullptr)) { check(false, "connect"); return; } // 客户端侧的会话 —— 它的写完成闭包由 `cli` 持有,而 `cli` 比会话活得久。 - auto* wc = new uvcpp_ws_connection(&cli, /*is_server=*/false); + auto* wc = new uvcpp_ws_connection(&cli, ws_role::CLIENT); sessions.adopt(wc); wc->start(); @@ -438,7 +438,7 @@ static void t_owner_terminate_with_inflight_write() { uvcpp_test::kWaitMs); if (!(connected && accepted != nullptr)) { check(false, "connect"); return; } - auto* wc = new uvcpp_ws_connection(&cli, /*is_server=*/false); + auto* wc = new uvcpp_ws_connection(&cli, ws_role::CLIENT); sessions.adopt(wc); wc->start(); diff --git a/tests/functional/web_ws_reassembly_func.cpp b/tests/functional/web_ws_reassembly_func.cpp index ba4f82b7..88b21104 100644 --- a/tests/functional/web_ws_reassembly_func.cpp +++ b/tests/functional/web_ws_reassembly_func.cpp @@ -152,7 +152,7 @@ struct scenario { // `set_loop` 必须在循环线程、循环正跑着的时候调(`uv_async_init` 不是 // 线程安全的),而这里正是接入回调,两个条件都满足。 sessions_.set_loop(server.get_loop()); - auto* wc = new uvcpp_ws_connection(c, /*is_server=*/true); + auto* wc = new uvcpp_ws_connection(c, ws_role::SERVER); sessions_.adopt(wc); if (max_msg > 0) wc->set_max_message_size(max_msg); wc->on_text([this](const std::string& m) { texts.push_back(m); }); From bf984059c366f8e57c48b6dad2b9fbe0f3fac637 Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:20:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test(ws):=20=E6=8A=8A"=E5=BF=85=E7=84=B6?= =?UTF-8?q?=E8=B7=A8=E5=A4=9A=E6=AC=A1=E8=AF=BB"=E4=BB=8E=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=8F=98=E6=88=90=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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。 --- tests/functional/web_ws_deflate_func.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/functional/web_ws_deflate_func.cpp b/tests/functional/web_ws_deflate_func.cpp index c5f43350..1bac2c41 100644 --- a/tests/functional/web_ws_deflate_func.cpp +++ b/tests/functional/web_ws_deflate_func.cpp @@ -563,6 +563,16 @@ static bool test_large_echo_across_reads() { const uvcpp_ws_frame& f = s.cframes[0]; if (f.opcode != ws_opcode::BINARY) return false; if (!f.rsv1) return false; + + // **把"必然跨多次读"从注释变成判据。** + // + // 上面那句「载荷压不动,所以这一帧必然跨多次读」原来只是注释,**没有任何 + // 断言** —— 哪天有人把负载换成可压缩的内容,压缩后的回显会缩到一次读完, + // 这条用例就**静默地**退化成"不跨读也算过",而它整条存在的意义就是打跨读 + // 那条路径。这比挂掉难查得多:测试还是绿的。 + // + // 256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量。 + if (f.payload.size() < 256 * 1024) return false; // 别拿压缩后的长度和明文比:`incompressible` 的数据 deflate 之后**更大** // (每 16KB 一个存储块,加 5 字节块头:512KiB 的负载量出来是 524449)。 // 只断言"解出来一样"。 From b2e850091532c8e951a2e956aa71e20e58310611 Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:22:52 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(ws):=20=E7=BB=99=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=9F=A5=E8=A1=A5=E4=B8=8A=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=86=85=E7=9A=84=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补这两条检查时没在仓库里留下任何用例 —— 只靠我外部的一个裸 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 --- tests/functional/web_ws_reassembly_func.cpp | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/functional/web_ws_reassembly_func.cpp b/tests/functional/web_ws_reassembly_func.cpp index 88b21104..3ec67465 100644 --- a/tests/functional/web_ws_reassembly_func.cpp +++ b/tests/functional/web_ws_reassembly_func.cpp @@ -437,6 +437,66 @@ static bool test_close_reason_truncated() { // 驱动 // ========================================================================= +// ========================================================================= +// 掩码的方向性与文本帧的 UTF-8 —— 两条 RFC 6455 的 MUST +// +// 这两条以前都没实现:服务端把**未掩码**的客户端帧当正常消息收下(§5.1 要求 +// 以 1002 关连接);文本帧里的**非法 UTF-8** 被原样交给 on_text(§8.1 要求 +// 关连接)。两条都不会崩、不会报错,只是"照单全收",所以能一直躺着。 +// +// 补上之后必须有用例盯着:否则哪天动了 on_ws_frame 里的判断顺序,它们会静悄悄 +// 回到老样子,而 CI 一片绿。 +// ========================================================================= + +/** @brief 服务端收到未掩码的客户端帧:§5.1 要求以 1002 关闭。 */ +static bool test_unmasked_frame_rejected() { + scenario s; + if (!s.start()) return false; + // mask=false:客户端**必须**掩码(§5.1),这里故意违反 + if (!s.send(raw_frame(0x1, true, "unmasked", false))) return false; + s.pump_until([&] { return !s.errors.empty(); }, 2000); + return s.errors.size() == 1 && + s.errors[0] == static_cast(ws_close_code::PROTOCOL_ERROR) && + s.texts.empty(); // 不该被当成正常消息交付 +} + +/** @brief 文本帧的负载不是合法 UTF-8:§8.1 要求关连接(1007)。 */ +static bool test_invalid_utf8_rejected() { + scenario s; + if (!s.start()) return false; + std::string bad; // 0xFF 0xFE 0xFD 不是合法 UTF-8 + bad.push_back(static_cast(0xFF)); + bad.push_back(static_cast(0xFE)); + bad.push_back(static_cast(0xFD)); + if (!s.send(raw_frame(0x1, true, bad))) return false; + s.pump_until([&] { return !s.errors.empty(); }, 2000); + return s.errors.size() == 1 && + s.errors[0] == static_cast(ws_close_code::INVALID_PAYLOAD) && + s.texts.empty(); +} + +/** + * @brief 反向对照:**合法**的多字节 UTF-8 必须照常交付。 + * + * 补 UTF-8 校验最容易的错法是规则写严了 —— 于是中文、emoji 全被拒掉,而一般 + * 用例只发 ASCII,根本发现不了。这条同时覆盖三字节与四字节两种长度。 + */ +static bool test_valid_utf8_still_delivered() { + scenario s; + if (!s.start()) return false; + std::string ok; + ok.push_back(static_cast(0xE4)); // 「中」= U+4E2D,三字节 + ok.push_back(static_cast(0xB8)); + ok.push_back(static_cast(0xAD)); + ok.push_back(static_cast(0xF0)); // 😀 = U+1F600,四字节 + ok.push_back(static_cast(0x9F)); + ok.push_back(static_cast(0x98)); + ok.push_back(static_cast(0x80)); + if (!s.send(raw_frame(0x1, true, ok))) return false; + s.pump_until([&] { return !s.texts.empty() || !s.errors.empty(); }, 2000); + return s.errors.empty() && s.texts.size() == 1 && s.texts[0] == ok; +} + int main() { bool ok = true; struct { const char* name; bool (*fn)(); } tests[] = { @@ -454,6 +514,10 @@ int main() { {"frame_cap", test_frame_cap}, {"send_queue_order", test_send_queue_order}, {"close_reason_truncated", test_close_reason_truncated}, + // 两条 RFC 6455 MUST + 一条反向对照(见上面的说明) + {"unmasked_frame_rejected", test_unmasked_frame_rejected}, + {"invalid_utf8_rejected", test_invalid_utf8_rejected}, + {"valid_utf8_still_delivered",test_valid_utf8_still_delivered}, }; for (const auto& t : tests) { std::cout << "[web_ws_reasm] " << t.name << "\n";