diff --git a/src/web/uvcpp_ws_client.cpp b/src/web/uvcpp_ws_client.cpp index 0dfa956e..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_); + 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_); + 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 c8edc37f..971d6f71 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, 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); }); @@ -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..ac5283db 100644 --- a/src/web/uvcpp_ws_connection.h +++ b/src/web/uvcpp_ws_connection.h @@ -42,13 +42,40 @@ 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) 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 role 本端角色。**必填、无默认值** —— RFC 6455 对两侧的帧要求是 + * **不对称**的:客户端发出的帧 MUST 掩码(§5.1),服务端发出的 + * MUST NOT(同节)。搞反了任意一侧都会直接不可用,所以让它必须被 + * 写出来,而不是靠一个"猜多半是服务端"的默认值。 + * + * **也别给它加默认值来"省一次编译错误"。** 两个默认方向都是静默坏: + * 默认 SERVER 则客户端漏填会发出未掩码帧(任何合规服务端都会 1002 + * 掉它),默认 CLIENT 则服务端漏填会发出带掩码的帧(合规客户端会关 + * 连接)。换句话说,"搞反了框架自己的往返用例会抓到"这条**只在 + * 框架自测自时成立**,而两侧同错恰好是最容易互相掩盖的一种。 + */ + uvcpp_ws_connection(uvcpp_tcp_client* tcp, ws_role role); /** * @brief 开始读 WS 帧。握手完成后调**一次**,且调用方已经不在 TCP 读回调里。 @@ -233,6 +260,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 +289,12 @@ class UVCPP_API uvcpp_ws_connection { void protocol_error(ws_close_code code, const std::string& reason); uvcpp_tcp_client* tcp_ = nullptr; + + /// 本端角色。决定掩码检查的方向(见构造函数的说明)。 + 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 ade685ee..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); + 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 14435e69..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); + * 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 2fcf3d4e..1bac2c41 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,8 +561,18 @@ 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; + + // **把"必然跨多次读"从注释变成判据。** + // + // 上面那句「载荷压不动,所以这一帧必然跨多次读」原来只是注释,**没有任何 + // 断言** —— 哪天有人把负载换成可压缩的内容,压缩后的回显会缩到一次读完, + // 这条用例就**静默地**退化成"不跨读也算过",而它整条存在的意义就是打跨读 + // 那条路径。这比挂掉难查得多:测试还是绿的。 + // + // 256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量。 + if (f.payload.size() < 256 * 1024) return false; // 别拿压缩后的长度和明文比:`incompressible` 的数据 deflate 之后**更大** // (每 16KB 一个存储块,加 5 字节块头:512KiB 的负载量出来是 524449)。 // 只断言"解出来一样"。 @@ -955,7 +981,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, 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 c1d7ccae..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); + 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); + 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); + 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 14f32c31..3ec67465 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, 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); }); @@ -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";