Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/web/uvcpp_ws_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
110 changes: 109 additions & 1 deletion src/web/uvcpp_ws_connection.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
#include <web/uvcpp_ws_connection.h>

#include <random>
#if UVCPP_WEB_ENABLE
#include <cstring>

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<char>(new char);
parser_.set_on_frame([this](const uvcpp_ws_frame& f) { on_ws_frame(f); });
Expand Down Expand Up @@ -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 字节 —— 这两条已经
Expand Down Expand Up @@ -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_;
Expand Down Expand Up @@ -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<const char*>(data), len) : std::string());
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<uint8_t>((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);
}

Expand All @@ -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 掉)。
Expand Down
46 changes: 44 additions & 2 deletions src/web/uvcpp_ws_connection.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 读回调里。
Expand Down Expand Up @@ -233,6 +260,15 @@ class UVCPP_API uvcpp_ws_connection {
/** @return 0 已入队/已发出;非 0 表示这一帧没能发出去(错误码)。 */
int send_frame(const uvcpp_ws_frame& frame, std::function<void(int)> 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
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/web/uvcpp_ws_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/web/uvcpp_ws_sessions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 36 additions & 10 deletions tests/functional/web_ws_deflate_func.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ struct scenario {
std::string handshake_resp; // 101 的原始报文

std::vector<std::string> server_texts;
/// 收到的 BINARY 消息。与 server_texts 分开存,判据才分得清"回没回错类型"。
std::vector<std::string> server_bins;
std::vector<uvcpp_ws_frame> cframes; // 服务端 → 客户端方向,测试侧解析
bool parse_error = false;

Expand Down Expand Up @@ -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<const char*>(d), n));
if (conn) conn->send_binary(reinterpret_cast<const char*>(d), n);
});
});

if (server->listen() != 0) { why = "listen"; return false; }
Expand Down Expand Up @@ -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;
Expand All @@ -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 没有
Expand Down Expand Up @@ -535,18 +547,32 @@ 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;
if (s.parse_error) return false;
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)。
// 只断言"解出来一样"。
Expand Down Expand Up @@ -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` 的返回值
// 是"有没有排上队",不是"有没有发出去"),所以这里断言回调。
Expand Down
Loading
Loading