From 649ddf912d4daf64079a8338b6876f77db9bead2 Mon Sep 17 00:00:00 2001 From: --global Date: Sun, 19 Jul 2026 00:47:36 +0300 Subject: [PATCH 1/7] feat: WebSocket upgrade and text echo on http::server Add RFC 6455 framing, Sec-WebSocket-Accept, and server.ws(path).ws(handler) registration. Upgrade takes over the connection after 101; middleware stays HTTP-phase only. Includes unit and loopback echo tests. Co-authored-by: Cursor --- README.md | 17 ++++ net/net-http_server.c++m | 90 +++++++++++++++++ net/net-websocket.c++m | 185 ++++++++++++++++++++++++++++++++++ net/net-websocket.test.c++ | 138 +++++++++++++++++++++++++ net/net-websocket_frame.c++m | 188 +++++++++++++++++++++++++++++++++++ net/net.c++m | 2 + 6 files changed, 620 insertions(+) create mode 100644 net/net-websocket.c++m create mode 100644 net/net-websocket.test.c++ create mode 100644 net/net-websocket_frame.c++m diff --git a/README.md b/README.md index ac908e3..920472a 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,23 @@ Use `html_escaped` for text nodes and attribute values. Use `url_encoded` for qu parameter values in `href` and `action` URLs. The HTTP server does not escape response bodies automatically. +# WebSocket (v1 spike) + +Upgrade is handled inside `http::server` (not as response middleware). Register a path +with `server.ws(...).ws(handler)`; a matching `GET` with `Upgrade: websocket` returns +`101` and runs a text-frame session (ping/pong/close + optional text replies). + +```cpp +import net; +import std; + +auto server = http::server{}; +server.ws("/echo").ws([](std::string_view msg) -> std::optional { + return std::string{msg}; // echo +}); +server.listen("127.0.0.1", "8080"); +``` + # Syslog Stream Example ## Basic Usage diff --git a/net/net-http_server.c++m b/net/net-http_server.c++m index dbe7a70..ad4a995 100644 --- a/net/net-http_server.c++m +++ b/net/net-http_server.c++m @@ -6,6 +6,7 @@ import :http_headers; import :endpointstream; import :utils; import :uuid; +import :websocket; import std; export namespace http { @@ -21,8 +22,10 @@ constexpr auto method_put = "PUT"sv; constexpr auto method_patch = "PATCH"sv; constexpr auto method_delete = "DELETE"sv; constexpr auto method_options = "OPTIONS"sv; +constexpr auto method_ws = "WS"sv; // HTTP status constants +const auto status_switching_protocols = "101 Switching Protocols"s; const auto status_ok = "200 OK"s; const auto status_created = "201 Created"s; const auto status_no_content = "204 No Content"s; @@ -82,6 +85,7 @@ class controller public: using callback = std::function<::http::response_with_headers(request_view, body_view, headers&)>; + using websocket_callback = net::websocket::text_handler; void text(const content& c) { @@ -153,10 +157,28 @@ public: return {std::move(s), std::move(c), m_content_type, std::move(custom_h)}; } + // WebSocket text handler: payload in → optional text frame out (nullopt = no reply). + controller& ws(websocket_callback cb) + { + m_ws_callback = std::move(cb); + return *this; + } + + [[nodiscard]] bool has_websocket() const noexcept + { + return static_cast(m_ws_callback); + } + + [[nodiscard]] const websocket_callback& websocket_handler() const noexcept + { + return m_ws_callback; + } + private: content_type m_content_type = "*/*"; callback m_callback = [](request_view, body_view, headers&) -> ::http::response_with_headers { return make_response(status_ok, "Not Found"s); }; + websocket_callback m_ws_callback; }; class server @@ -199,6 +221,12 @@ public: return m_router[std::string{path}][method_delete]; } + // Register a WebSocket endpoint (upgrade from GET). Chain `.ws(handler)`. + controller& ws(std::string_view path) + { + return m_router[std::string{path}][method_ws]; + } + void listen(std::string_view service_or_port = "http") { listen("0.0.0.0"sv, service_or_port, nullptr); @@ -453,6 +481,68 @@ private: break; } + // WebSocket upgrade takes over the connection (not a normal HTTP response). + if(method == method_get and net::websocket::is_websocket_upgrade(hs)) + { + const auto path_only = std::string{uri.substr(0, uri.find('?'))}; + auto matched_path = std::optional{}; + for(const auto& [path, methods_map] : m_router) + { + if(not std::regex_match(path_only, std::regex{path})) + continue; + if(methods_map.contains(method_ws) and methods_map.at(method_ws).has_websocket()) + { + matched_path = path; + break; + } + } + + if(not matched_path) + { + stream << "HTTP/1.1 " << status_not_found << net::crlf + << "Date: " << date() << net::crlf + << "Server: " << host() << net::crlf + << "Content-Type: " << m_content_type << net::crlf + << "Content-Length: 0" << net::crlf + << "Connection: close" << net::crlf + << net::crlf << net::flush; + break; + } + + if(not hs.contains("sec-websocket-key") or hs["sec-websocket-key"].empty()) + { + stream << "HTTP/1.1 " << status_bad_request << net::crlf + << "Date: " << date() << net::crlf + << "Server: " << host() << net::crlf + << "Content-Type: " << m_content_type << net::crlf + << "Content-Length: 0" << net::crlf + << "Connection: close" << net::crlf + << net::crlf << net::flush; + break; + } + + const auto accept = net::websocket::sec_websocket_accept(hs["sec-websocket-key"]); + net::slog << net::notice("HTTP_WEBSOCKET_UPGRADE") << "upgrading connection" + << std::pair{"ip"sv, endpoint} + << std::pair{"port", port} + << std::pair{"uri", std::string{uri}} + << std::pair{"request_id", request_id} + << net::flush; + + stream << "HTTP/1.1 " << status_switching_protocols << net::crlf + << "Date: " << date() << net::crlf + << "Server: " << host() << net::crlf + << "Upgrade: websocket" << net::crlf + << "Connection: Upgrade" << net::crlf + << "Sec-WebSocket-Accept: " << accept << net::crlf + << net::crlf << net::flush; + + // Copy handler before session (flat_map proxies are not stable refs). + const auto ws_handler = m_router.at(*matched_path).at(method_ws).websocket_handler(); + net::websocket::run_text_session(stream, ws_handler); + break; + } + auto close_connection = false; if(hs.contains("connection")) { diff --git a/net/net-websocket.c++m b/net/net-websocket.c++m new file mode 100644 index 0000000..fa33bf3 --- /dev/null +++ b/net/net-websocket.c++m @@ -0,0 +1,185 @@ +export module net:websocket; +export import :websocket_frame; +import :http_base64; +import :http_headers; +import :utils; +import std; + +export namespace net::websocket { + +using namespace std::string_literals; +using namespace std::string_view_literals; + +// RFC 6455 §1.3 magic GUID +inline constexpr auto accept_guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"sv; + +namespace detail { + +// Compact SHA-1 (FIPS 180-1) for the short WebSocket accept material only. +inline std::array sha1_digest(std::string_view message) +{ + auto left_rotate = [](std::uint32_t value, std::uint32_t bits) noexcept + { + return (value << bits) | (value >> (32u - bits)); + }; + + auto h0 = 0x67452301u; + auto h1 = 0xEFCDAB89u; + auto h2 = 0x98BADCFEu; + auto h3 = 0x10325476u; + auto h4 = 0xC3D2E1F0u; + + auto bytes = std::vector(message.begin(), message.end()); + const auto bit_len = static_cast(bytes.size()) * 8ull; + bytes.push_back(0x80u); + while((bytes.size() % 64u) != 56u) + bytes.push_back(0u); + for(int shift = 56; shift >= 0; shift -= 8) + bytes.push_back(static_cast((bit_len >> shift) & 0xFFu)); + + for(std::size_t offset = 0; offset < bytes.size(); offset += 64) + { + std::array w{}; + for(std::size_t i = 0; i < 16; ++i) + { + w[i] = (static_cast(bytes[offset + i * 4]) << 24) + | (static_cast(bytes[offset + i * 4 + 1]) << 16) + | (static_cast(bytes[offset + i * 4 + 2]) << 8) + | static_cast(bytes[offset + i * 4 + 3]); + } + for(std::size_t i = 16; i < 80; ++i) + w[i] = left_rotate(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + + auto a = h0; + auto b = h1; + auto c = h2; + auto d = h3; + auto e = h4; + + for(std::size_t i = 0; i < 80; ++i) + { + std::uint32_t f = 0; + std::uint32_t k = 0; + if(i < 20) + { + f = (b & c) | ((~b) & d); + k = 0x5A827999u; + } + else if(i < 40) + { + f = b ^ c ^ d; + k = 0x6ED9EBA1u; + } + else if(i < 60) + { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDCu; + } + else + { + f = b ^ c ^ d; + k = 0xCA62C1D6u; + } + + const auto temp = left_rotate(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = left_rotate(b, 30); + b = a; + a = temp; + } + + h0 += a; + h1 += b; + h2 += c; + h3 += d; + h4 += e; + } + + auto digest = std::array{}; + const auto words = std::array{h0, h1, h2, h3, h4}; + for(std::size_t i = 0; i < words.size(); ++i) + { + digest[i * 4] = static_cast((words[i] >> 24) & 0xFFu); + digest[i * 4 + 1] = static_cast((words[i] >> 16) & 0xFFu); + digest[i * 4 + 2] = static_cast((words[i] >> 8) & 0xFFu); + digest[i * 4 + 3] = static_cast(words[i] & 0xFFu); + } + return digest; +} + +} // namespace detail + +inline std::string sec_websocket_accept(std::string_view client_key) +{ + auto material = std::string{client_key}; + material.append(accept_guid); + const auto digest = detail::sha1_digest(material); + return ::http::base64::encode( + std::string_view{reinterpret_cast(digest.data()), digest.size()}); +} + +inline bool header_token_contains(std::string value, std::string_view token) +{ + utils::ascii_to_lower(value); + auto needle = std::string{token}; + utils::ascii_to_lower(needle); + return value.contains(needle); +} + +inline bool is_websocket_upgrade(const ::http::headers& hs) +{ + if(not hs.contains("upgrade") or not hs.contains("connection")) + return false; + return header_token_contains(hs["upgrade"], "websocket"sv) + and header_token_contains(hs["connection"], "upgrade"sv); +} + +using text_handler = std::function(std::string_view)>; + +// Drive a WebSocket session until close/error. Server frames are unmasked. +inline void run_text_session(std::iostream& stream, const text_handler& on_text) +{ + while(stream) + { + auto incoming = frame{}; + if(not read_frame(stream, incoming)) + break; + + switch(incoming.op) + { + case opcode::ping: + if(not write_frame(stream, make_pong_frame(incoming.payload))) + return; + break; + + case opcode::pong: + break; + + case opcode::close: + write_frame(stream, make_close_frame(incoming.payload)); + return; + + case opcode::text: + { + if(not on_text) + break; + const auto text = payload_as_string(incoming); + if(auto reply = on_text(text)) + { + if(not write_frame(stream, make_text_frame(*reply))) + return; + } + break; + } + + case opcode::binary: + case opcode::continuation: + default: + // v1: ignore unsupported data opcodes + break; + } + } +} + +} // namespace net::websocket diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ new file mode 100644 index 0000000..7ccb92e --- /dev/null +++ b/net/net-websocket.test.c++ @@ -0,0 +1,138 @@ +module net; +import tester; +import std; + +using namespace net; +using namespace net::websocket; +using namespace std::string_literals; +using namespace std::string_view_literals; + +namespace { + +using tester::assertions::check_eq; +using tester::assertions::check_true; +using tester::assertions::require_true; + +inline bool network_tests_enabled() +{ + if(const auto* v = std::getenv("NET_DISABLE_NETWORK_TESTS")) + return std::string_view{v} != "1"; + return true; +} + +inline frame make_masked_text(std::string_view text, std::uint32_t key = 0x01020304u) +{ + auto f = make_text_frame(text); + f.masked = true; + f.masking_key = key; + return f; +} + +inline frame make_masked_close(std::uint32_t key = 0x01020304u) +{ + auto f = make_close_frame(); + f.masked = true; + f.masking_key = key; + return f; +} + +} // namespace + +auto register_websocket_tests() +{ + tester::bdd::scenario("sec_websocket_accept matches RFC 6455 example, [net]") = [] { + // RFC 6455 §1.3 + const auto accept = sec_websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="sv); + check_eq(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="s); + }; + + tester::bdd::scenario("websocket frame round-trip with client mask, [net]") = [] { + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text("hello"sv))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded)); + check_eq(decoded.op, opcode::text); + check_true(decoded.fin); + check_eq(payload_as_string(decoded), "hello"s); + }; + + tester::bdd::scenario("websocket frame supports 16-bit extended length, [net]") = [] { + const auto payload = std::string(200, 'x'); + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text(payload))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded)); + check_eq(payload_as_string(decoded), payload); + }; + + tester::bdd::scenario("http server websocket echo upgrade, [net]") = [] { + if(not network_tests_enabled()) + return; + + auto server = std::make_shared(); + server->ws("/echo").ws([](std::string_view msg) -> std::optional { + return std::string{msg}; + }); + + std::promise bound; + auto bound_future = bound.get_future(); + std::thread server_thread{[server, &bound] { + try + { + server->listen("127.0.0.1"sv, "18090"sv, [&bound] { bound.set_value(); }); + } + catch(...) + { + try { bound.set_value(); } catch(...) {} + } + }}; + + using namespace std::chrono_literals; + require_true(bound_future.wait_for(3s) == std::future_status::ready); + std::this_thread::sleep_for(50ms); + + auto client = connect("127.0.0.1", "18090"); + client << "GET /echo HTTP/1.1" << net::crlf + << "Host: 127.0.0.1:18090" << net::crlf + << "Upgrade: websocket" << net::crlf + << "Connection: Upgrade" << net::crlf + << "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" << net::crlf + << "Sec-WebSocket-Version: 13" << net::crlf + << net::crlf << net::flush; + + auto version = ""s; + auto status_code = ""s; + auto reason = ""s; + client >> version >> status_code; + std::getline(client, reason); + check_eq(status_code, "101"s); + + auto hs = http::headers{}; + client >> hs >> net::crlf; + check_true(hs.contains("sec-websocket-accept")); + check_eq(hs["sec-websocket-accept"], "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="s); + + require_true(write_frame(client, make_masked_text("ping-me"sv))); + auto reply = frame{}; + require_true(read_frame(client, reply)); + check_eq(reply.op, opcode::text); + check_eq(payload_as_string(reply), "ping-me"s); + + require_true(write_frame(client, make_masked_close())); + auto close_reply = frame{}; + require_true(read_frame(client, close_reply)); + check_eq(close_reply.op, opcode::close); + + server->stop(); + if(server_thread.joinable()) + server_thread.join(); + }; + + return true; +} + +const auto _ = register_websocket_tests(); diff --git a/net/net-websocket_frame.c++m b/net/net-websocket_frame.c++m new file mode 100644 index 0000000..f4eb8e2 --- /dev/null +++ b/net/net-websocket_frame.c++m @@ -0,0 +1,188 @@ +export module net:websocket_frame; +import std; + +// RFC 6455 WebSocket framing (byte-oriented; no bitfield endian surprises). + +export namespace net::websocket { + +using namespace std::string_literals; +using namespace std::string_view_literals; + +enum class opcode : std::uint8_t +{ + continuation = 0x0, + text = 0x1, + binary = 0x2, + close = 0x8, + ping = 0x9, + pong = 0xA, +}; + +struct frame +{ + bool fin = true; + opcode op = opcode::text; + bool masked = false; + std::uint32_t masking_key = 0; + std::vector payload; +}; + +inline void apply_mask(std::span data, std::uint32_t key) noexcept +{ + const auto key_bytes = std::array{ + static_cast((key >> 24) & 0xFFu), + static_cast((key >> 16) & 0xFFu), + static_cast((key >> 8) & 0xFFu), + static_cast(key & 0xFFu)}; + for(std::size_t i = 0; i < data.size(); ++i) + data[i] ^= key_bytes[i % 4]; +} + +inline frame make_text_frame(std::string_view text) +{ + auto f = frame{}; + f.op = opcode::text; + f.payload.resize(text.size()); + std::memcpy(f.payload.data(), text.data(), text.size()); + return f; +} + +inline frame make_close_frame(std::span payload = {}) +{ + auto f = frame{}; + f.op = opcode::close; + f.payload.assign(payload.begin(), payload.end()); + return f; +} + +inline frame make_pong_frame(std::span payload) +{ + auto f = frame{}; + f.op = opcode::pong; + f.payload.assign(payload.begin(), payload.end()); + return f; +} + +inline std::string payload_as_string(const frame& f) +{ + return std::string{ + reinterpret_cast(f.payload.data()), + f.payload.size()}; +} + +inline bool write_frame(std::ostream& os, const frame& f) +{ + const auto len = f.payload.size(); + auto b0 = static_cast(static_cast(f.op) & 0x0Fu); + if(f.fin) + b0 |= 0x80u; + + auto b1 = static_cast(f.masked ? 0x80u : 0u); + if(len <= 125) + b1 |= static_cast(len); + else if(len <= 0xFFFFu) + b1 |= 126u; + else + b1 |= 127u; + + os.put(static_cast(b0)); + os.put(static_cast(b1)); + + if(len > 125 and len <= 0xFFFFu) + { + os.put(static_cast((len >> 8) & 0xFFu)); + os.put(static_cast(len & 0xFFu)); + } + else if(len > 0xFFFFu) + { + for(int shift = 56; shift >= 0; shift -= 8) + os.put(static_cast((len >> shift) & 0xFFu)); + } + + auto payload = f.payload; + if(f.masked) + { + apply_mask(payload, f.masking_key); + os.put(static_cast((f.masking_key >> 24) & 0xFFu)); + os.put(static_cast((f.masking_key >> 16) & 0xFFu)); + os.put(static_cast((f.masking_key >> 8) & 0xFFu)); + os.put(static_cast(f.masking_key & 0xFFu)); + } + + os.write(reinterpret_cast(payload.data()), static_cast(payload.size())); + os.flush(); + return static_cast(os); +} + +inline bool read_frame(std::istream& is, frame& f) +{ + f = frame{}; + const auto c0 = is.get(); + const auto c1 = is.get(); + if(not is) + return false; + + const auto b0 = static_cast(c0); + const auto b1 = static_cast(c1); + f.fin = (b0 & 0x80u) != 0; + f.op = static_cast(b0 & 0x0Fu); + f.masked = (b1 & 0x80u) != 0; + auto len = static_cast(b1 & 0x7Fu); + + if(len == 126) + { + const auto hi = is.get(); + const auto lo = is.get(); + if(not is) + return false; + len = (static_cast(static_cast(hi)) << 8) + | static_cast(lo); + } + else if(len == 127) + { + len = 0; + for(int i = 0; i < 8; ++i) + { + const auto b = is.get(); + if(not is) + return false; + len = (len << 8) | static_cast(b); + } + } + + // v1 guard — reject absurd frames early + constexpr auto max_payload = std::uint64_t{1u << 20}; // 1 MiB + if(len > max_payload) + return false; + + if(f.masked) + { + std::array key_bytes{}; + for(auto& b : key_bytes) + { + const auto c = is.get(); + if(not is) + return false; + b = static_cast(c); + } + f.masking_key = (static_cast(key_bytes[0]) << 24) + | (static_cast(key_bytes[1]) << 16) + | (static_cast(key_bytes[2]) << 8) + | static_cast(key_bytes[3]); + } + + f.payload.resize(static_cast(len)); + if(len > 0) + { + is.read(reinterpret_cast(f.payload.data()), static_cast(len)); + if(not is or static_cast(is.gcount()) != len) + return false; + } + + if(f.masked) + apply_mask(f.payload, f.masking_key); + + return true; +} + +} // namespace net::websocket diff --git a/net/net.c++m b/net/net.c++m index 5062722..ac0ee1c 100644 --- a/net/net.c++m +++ b/net/net.c++m @@ -20,3 +20,5 @@ export import :structured_log_stream; export import :uri; export import :utils; export import :uuid; +export import :websocket; +export import :websocket_frame; From efb1da61ae0844c9f10f2911b51ee98ec1501578 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 23:27:02 +0000 Subject: [PATCH 2/7] test: cover websocket frame edge cases and run_text_session control flow Add unit tests exercising previously-uncovered paths: - unmasked server frame round-trip - 64-bit extended length frames - read_frame rejection of oversized (>1 MiB) and truncated payloads - run_text_session text echo, nullopt suppression, ping->pong, pong-ignore, and close handshake termination Uses an in-memory duplex stream helper so run_text_session is exercised without a socket, so the new cases run even when network tests are disabled. Co-authored-by: Kaius Ruokonen --- net/net-websocket.test.c++ | 191 +++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ index 7ccb92e..7a444f0 100644 --- a/net/net-websocket.test.c++ +++ b/net/net-websocket.test.c++ @@ -36,6 +36,88 @@ inline frame make_masked_close(std::uint32_t key = 0x01020304u) return f; } +inline frame make_masked_ping(std::string_view payload, std::uint32_t key = 0x01020304u) +{ + auto f = frame{}; + f.op = opcode::ping; + f.masked = true; + f.masking_key = key; + f.payload.resize(payload.size()); + if(not payload.empty()) + std::memcpy(f.payload.data(), payload.data(), payload.size()); + return f; +} + +inline frame make_masked_pong(std::uint32_t key = 0x01020304u) +{ + auto f = frame{}; + f.op = opcode::pong; + f.masked = true; + f.masking_key = key; + return f; +} + +// In-memory duplex stream: reads consume a prepared input buffer, writes are +// captured separately so run_text_session responses can be inspected without a +// socket. Keeping the two directions apart avoids the reader looping over freshly +// written response bytes (which a single shared buffer would do). +class duplex_buf : public std::streambuf +{ +public: + + explicit duplex_buf(std::string input) : m_in{std::move(input)} + { + auto* base = m_in.data(); + setg(base, base, base + m_in.size()); + } + + const std::string& output() const noexcept { return m_out; } + +protected: + + int_type overflow(int_type ch) override + { + if(ch != traits_type::eof()) + m_out.push_back(static_cast(ch)); + return ch; + } + + std::streamsize xsputn(const char* s, std::streamsize n) override + { + m_out.append(s, static_cast(n)); + return n; + } + +private: + + std::string m_in; + std::string m_out; +}; + +class duplex_stream : public std::iostream +{ +public: + + explicit duplex_stream(std::string input) : std::iostream{nullptr}, m_buf{std::move(input)} + { + rdbuf(&m_buf); + } + + const std::string& output() const noexcept { return m_buf.output(); } + +private: + + duplex_buf m_buf; +}; + +// Encode a frame to bytes for use as duplex_stream input. +inline std::string frame_bytes(const frame& f) +{ + auto oss = std::ostringstream{}; + write_frame(oss, f); + return oss.str(); +} + } // namespace auto register_websocket_tests() @@ -69,6 +151,115 @@ auto register_websocket_tests() check_eq(payload_as_string(decoded), payload); }; + tester::bdd::scenario("websocket unmasked server frame round-trip, [net]") = [] { + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_text_frame("server-say"sv))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded)); + check_true(not decoded.masked); + check_eq(decoded.op, opcode::text); + check_eq(payload_as_string(decoded), "server-say"s); + }; + + tester::bdd::scenario("websocket frame supports 64-bit extended length, [net]") = [] { + // > 0xFFFF forces the 8-byte length encoding, still under the 1 MiB cap. + const auto payload = std::string(70000, 'y'); + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text(payload))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded)); + check_eq(decoded.payload.size(), payload.size()); + check_eq(payload_as_string(decoded), payload); + }; + + tester::bdd::scenario("websocket read_frame rejects oversized payload, [net]") = [] { + // Hand-craft a header that claims > 1 MiB via the 64-bit length field. + auto header = std::string{}; + header.push_back(static_cast(0x81)); // FIN + text + header.push_back(static_cast(0x7F)); // 127 => 64-bit length, unmasked + const auto len = std::uint64_t{(1u << 20) + 1}; + for(int shift = 56; shift >= 0; shift -= 8) + header.push_back(static_cast((len >> shift) & 0xFFu)); + + auto iss = std::istringstream{header}; + auto decoded = frame{}; + check_true(not read_frame(iss, decoded)); + }; + + tester::bdd::scenario("websocket read_frame fails on truncated payload, [net]") = [] { + // Announce 5 bytes but only provide 2. + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text("hello"sv))); + auto truncated = oss.str().substr(0, oss.str().size() - 3); + + auto iss = std::istringstream{truncated}; + auto decoded = frame{}; + check_true(not read_frame(iss, decoded)); + }; + + tester::bdd::scenario("run_text_session echoes text via handler, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_text("hello"sv))}; + run_text_session(stream, [](std::string_view msg) -> std::optional { + return std::string{msg} + "!"; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply)); + check_true(not reply.masked); + check_eq(reply.op, opcode::text); + check_eq(payload_as_string(reply), "hello!"s); + }; + + tester::bdd::scenario("run_text_session suppresses reply when handler returns nullopt, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_text("quiet"sv))}; + run_text_session(stream, [](std::string_view) -> std::optional { + return std::nullopt; + }); + check_true(stream.output().empty()); + }; + + tester::bdd::scenario("run_text_session answers ping with pong, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_ping("hb"sv))}; + run_text_session(stream, nullptr); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply)); + check_eq(reply.op, opcode::pong); + check_eq(payload_as_string(reply), "hb"s); + }; + + tester::bdd::scenario("run_text_session ignores pong frames, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_pong())}; + run_text_session(stream, nullptr); + check_true(stream.output().empty()); + }; + + tester::bdd::scenario("run_text_session replies to close and ends, [net]") = [] { + // A close frame followed by a text frame: the session must stop after the + // close and never echo the trailing text. + auto input = frame_bytes(make_masked_close()); + input += frame_bytes(make_masked_text("after-close"sv)); + + auto stream = duplex_stream{input}; + run_text_session(stream, [](std::string_view msg) -> std::optional { + return std::string{msg}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply)); + check_eq(reply.op, opcode::close); + + auto extra = frame{}; + check_true(not read_frame(out, extra)); // nothing echoed after close + }; + tester::bdd::scenario("http server websocket echo upgrade, [net]") = [] { if(not network_tests_enabled()) return; From 47add9bc9a5242cec381baa80df487722fd26528 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 23:31:14 +0000 Subject: [PATCH 3/7] feat: enforce RFC 6455 client frame masking on the server Per RFC 6455 5.1, client-to-server frames must be masked. run_text_session now rejects any unmasked incoming frame with a 1002 (protocol error) close and ends the session. Adds close status-code constants and a make_close_frame overload that carries a 2-byte big-endian code. Co-authored-by: Kaius Ruokonen --- README.md | 3 +++ net/net-websocket.c++m | 8 ++++++++ net/net-websocket_frame.c++m | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 920472a..62aab09 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,9 @@ Upgrade is handled inside `http::server` (not as response middleware). Register with `server.ws(...).ws(handler)`; a matching `GET` with `Upgrade: websocket` returns `101` and runs a text-frame session (ping/pong/close + optional text replies). +Per RFC 6455 §5.1, client-to-server frames must be masked; an unmasked frame is +rejected with a `1002` (protocol error) close and ends the session. + ```cpp import net; import std; diff --git a/net/net-websocket.c++m b/net/net-websocket.c++m index fa33bf3..3f3986f 100644 --- a/net/net-websocket.c++m +++ b/net/net-websocket.c++m @@ -146,6 +146,14 @@ inline void run_text_session(std::iostream& stream, const text_handler& on_text) if(not read_frame(stream, incoming)) break; + // RFC 6455 §5.1: every client-to-server frame MUST be masked. Reject an + // unmasked frame with a 1002 (protocol error) close and end the session. + if(not incoming.masked) + { + write_frame(stream, make_close_frame(close_protocol_error)); + return; + } + switch(incoming.op) { case opcode::ping: diff --git a/net/net-websocket_frame.c++m b/net/net-websocket_frame.c++m index f4eb8e2..f0e5d81 100644 --- a/net/net-websocket_frame.c++m +++ b/net/net-websocket_frame.c++m @@ -18,6 +18,10 @@ enum class opcode : std::uint8_t pong = 0xA, }; +// RFC 6455 §7.4.1 close status codes (subset used by this server). +inline constexpr std::uint16_t close_normal = 1000; +inline constexpr std::uint16_t close_protocol_error = 1002; + struct frame { bool fin = true; @@ -55,6 +59,17 @@ inline frame make_close_frame(std::span payload = {}) return f; } +// Close frame carrying a 2-byte big-endian status code (RFC 6455 §5.5.1). +inline frame make_close_frame(std::uint16_t status_code) +{ + auto f = frame{}; + f.op = opcode::close; + f.payload.resize(2); + f.payload[0] = static_cast((status_code >> 8) & 0xFFu); + f.payload[1] = static_cast(status_code & 0xFFu); + return f; +} + inline frame make_pong_frame(std::span payload) { auto f = frame{}; From 480fe52778e8f636945ec7f8a367085bbcc7fd54 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 23:31:14 +0000 Subject: [PATCH 4/7] test: verify server rejects unmasked client frames with 1002 close Co-authored-by: Kaius Ruokonen --- net/net-websocket.test.c++ | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ index 7a444f0..454d203 100644 --- a/net/net-websocket.test.c++ +++ b/net/net-websocket.test.c++ @@ -260,6 +260,31 @@ auto register_websocket_tests() check_true(not read_frame(out, extra)); // nothing echoed after close }; + tester::bdd::scenario("run_text_session rejects unmasked client frame with 1002, [net]") = [] { + // RFC 6455 §5.1: client frames must be masked. An unmasked text frame must + // be rejected with a protocol-error close and the handler must not run. + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{frame_bytes(make_text_frame("nomask"sv))}; + run_text_session(stream, [handler_ran](std::string_view msg) -> std::optional { + *handler_ran = true; + return std::string{msg}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply)); + check_eq(reply.op, opcode::close); + require_true(reply.payload.size() >= 2); + const auto code = static_cast( + (std::to_integer(reply.payload[0]) << 8) + | std::to_integer(reply.payload[1])); + check_eq(code, close_protocol_error); + check_true(not *handler_ran); + + auto extra = frame{}; + check_true(not read_frame(out, extra)); // session ended after the close + }; + tester::bdd::scenario("http server websocket echo upgrade, [net]") = [] { if(not network_tests_enabled()) return; From f18d3383067d4e77c70799c5ee3898f82dcf57aa Mon Sep 17 00:00:00 2001 From: --global Date: Sun, 19 Jul 2026 03:21:43 +0300 Subject: [PATCH 5/7] feat: reject WS fragments, enforce UTF-8 and control-frame rules Fail closed with close codes: no fragmentation/binary (1003), invalid UTF-8 (1007), oversized payloads (1009), RSV/control violations (1002). UTF-8 validation is self-contained in net (no xson dependency). Co-authored-by: Cursor --- README.md | 9 +- net/net-websocket.c++m | 43 ++++++++-- net/net-websocket.test.c++ | 154 +++++++++++++++++++++++++++++----- net/net-websocket_frame.c++m | 157 ++++++++++++++++++++++++++++++++--- 4 files changed, 324 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 62aab09..9360e61 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,13 @@ Upgrade is handled inside `http::server` (not as response middleware). Register with `server.ws(...).ws(handler)`; a matching `GET` with `Upgrade: websocket` returns `101` and runs a text-frame session (ping/pong/close + optional text replies). -Per RFC 6455 §5.1, client-to-server frames must be masked; an unmasked frame is -rejected with a `1002` (protocol error) close and ends the session. +v1 framing policy (fail closed with a close frame, no silent drops): + +- Client-to-server frames must be masked (`1002` if not). +- Complete text frames only — `FIN=0`, `continuation`, and `binary` → `1003`. +- Text payloads must be valid UTF-8 (`1007`); no xson dependency. +- Control frames must be FIN and ≤ 125 bytes; RSV bits must be 0 (`1002`). +- Payloads larger than 1 MiB → `1009`. ```cpp import net; diff --git a/net/net-websocket.c++m b/net/net-websocket.c++m index 3f3986f..40d5736 100644 --- a/net/net-websocket.c++m +++ b/net/net-websocket.c++m @@ -137,20 +137,37 @@ inline bool is_websocket_upgrade(const ::http::headers& hs) using text_handler = std::function(std::string_view)>; +inline void close_session(std::iostream& stream, std::uint16_t code) +{ + write_frame(stream, make_close_frame(code)); +} + // Drive a WebSocket session until close/error. Server frames are unmasked. +// v1 policy: complete text frames only (no fragmentation / binary); invalid +// UTF-8 → 1007; structural frame errors → 1002 / 1009. inline void run_text_session(std::iostream& stream, const text_handler& on_text) { while(stream) { auto incoming = frame{}; - if(not read_frame(stream, incoming)) + switch(read_frame(stream, incoming)) + { + case frame_read::ok: break; + case frame_read::io_error: + return; + case frame_read::message_too_big: + close_session(stream, close_message_too_big); + return; + case frame_read::protocol_error: + close_session(stream, close_protocol_error); + return; + } - // RFC 6455 §5.1: every client-to-server frame MUST be masked. Reject an - // unmasked frame with a 1002 (protocol error) close and end the session. + // RFC 6455 §5.1: every client-to-server frame MUST be masked. if(not incoming.masked) { - write_frame(stream, make_close_frame(close_protocol_error)); + close_session(stream, close_protocol_error); return; } @@ -170,6 +187,17 @@ inline void run_text_session(std::iostream& stream, const text_handler& on_text) case opcode::text: { + // No reassembly: FIN=0 is unsupported (close 1003), not silently dropped. + if(not incoming.fin) + { + close_session(stream, close_unsupported_data); + return; + } + if(not is_valid_utf8(std::span{incoming.payload})) + { + close_session(stream, close_invalid_payload); + return; + } if(not on_text) break; const auto text = payload_as_string(incoming); @@ -183,9 +211,12 @@ inline void run_text_session(std::iostream& stream, const text_handler& on_text) case opcode::binary: case opcode::continuation: + close_session(stream, close_unsupported_data); + return; + default: - // v1: ignore unsupported data opcodes - break; + close_session(stream, close_protocol_error); + return; } } } diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ index 454d203..e39c921 100644 --- a/net/net-websocket.test.c++ +++ b/net/net-websocket.test.c++ @@ -134,7 +134,7 @@ auto register_websocket_tests() auto iss = std::istringstream{oss.str()}; auto decoded = frame{}; - require_true(read_frame(iss, decoded)); + require_true(read_frame(iss, decoded) == frame_read::ok); check_eq(decoded.op, opcode::text); check_true(decoded.fin); check_eq(payload_as_string(decoded), "hello"s); @@ -147,7 +147,7 @@ auto register_websocket_tests() auto iss = std::istringstream{oss.str()}; auto decoded = frame{}; - require_true(read_frame(iss, decoded)); + require_true(read_frame(iss, decoded) == frame_read::ok); check_eq(payload_as_string(decoded), payload); }; @@ -157,7 +157,7 @@ auto register_websocket_tests() auto iss = std::istringstream{oss.str()}; auto decoded = frame{}; - require_true(read_frame(iss, decoded)); + require_true(read_frame(iss, decoded) == frame_read::ok); check_true(not decoded.masked); check_eq(decoded.op, opcode::text); check_eq(payload_as_string(decoded), "server-say"s); @@ -171,7 +171,7 @@ auto register_websocket_tests() auto iss = std::istringstream{oss.str()}; auto decoded = frame{}; - require_true(read_frame(iss, decoded)); + require_true(read_frame(iss, decoded) == frame_read::ok); check_eq(decoded.payload.size(), payload.size()); check_eq(payload_as_string(decoded), payload); }; @@ -187,7 +187,7 @@ auto register_websocket_tests() auto iss = std::istringstream{header}; auto decoded = frame{}; - check_true(not read_frame(iss, decoded)); + check_eq(read_frame(iss, decoded), frame_read::message_too_big); }; tester::bdd::scenario("websocket read_frame fails on truncated payload, [net]") = [] { @@ -198,7 +198,7 @@ auto register_websocket_tests() auto iss = std::istringstream{truncated}; auto decoded = frame{}; - check_true(not read_frame(iss, decoded)); + check_true(read_frame(iss, decoded) != frame_read::ok); }; tester::bdd::scenario("run_text_session echoes text via handler, [net]") = [] { @@ -209,7 +209,7 @@ auto register_websocket_tests() auto out = std::istringstream{stream.output()}; auto reply = frame{}; - require_true(read_frame(out, reply)); + require_true(read_frame(out, reply) == frame_read::ok); check_true(not reply.masked); check_eq(reply.op, opcode::text); check_eq(payload_as_string(reply), "hello!"s); @@ -229,7 +229,7 @@ auto register_websocket_tests() auto out = std::istringstream{stream.output()}; auto reply = frame{}; - require_true(read_frame(out, reply)); + require_true(read_frame(out, reply) == frame_read::ok); check_eq(reply.op, opcode::pong); check_eq(payload_as_string(reply), "hb"s); }; @@ -253,11 +253,11 @@ auto register_websocket_tests() auto out = std::istringstream{stream.output()}; auto reply = frame{}; - require_true(read_frame(out, reply)); + require_true(read_frame(out, reply) == frame_read::ok); check_eq(reply.op, opcode::close); auto extra = frame{}; - check_true(not read_frame(out, extra)); // nothing echoed after close + check_true(read_frame(out, extra) != frame_read::ok); // nothing echoed after close }; tester::bdd::scenario("run_text_session rejects unmasked client frame with 1002, [net]") = [] { @@ -272,17 +272,133 @@ auto register_websocket_tests() auto out = std::istringstream{stream.output()}; auto reply = frame{}; - require_true(read_frame(out, reply)); + require_true(read_frame(out, reply) == frame_read::ok); check_eq(reply.op, opcode::close); - require_true(reply.payload.size() >= 2); - const auto code = static_cast( - (std::to_integer(reply.payload[0]) << 8) - | std::to_integer(reply.payload[1])); - check_eq(code, close_protocol_error); + check_eq(close_code(reply), close_protocol_error); check_true(not *handler_ran); auto extra = frame{}; - check_true(not read_frame(out, extra)); // session ended after the close + check_true(read_frame(out, extra) != frame_read::ok); // session ended after the close + }; + + tester::bdd::scenario("is_valid_utf8 accepts text and rejects illegal bytes, [net]") = [] { + check_true(is_valid_utf8("hello"sv)); + check_true(is_valid_utf8("äö€"sv)); // multi-byte OK + check_true(is_valid_utf8(""sv)); + + const auto overlong = std::array{std::byte{0xC0}, std::byte{0x80}}; // U+0000 overlong + check_true(not is_valid_utf8(std::span{overlong})); + + const auto bad = std::array{std::byte{0xFFu}}; + check_true(not is_valid_utf8(std::span{bad})); + + const auto truncated = std::array{std::byte{0xE2}, std::byte{0x82}}; // incomplete € + check_true(not is_valid_utf8(std::span{truncated})); + }; + + tester::bdd::scenario("read_frame rejects RSV bits and oversized control, [net]") = [] { + // FIN + text with RSV1 set. + auto rsv = std::string{}; + rsv.push_back(static_cast(0xC1)); // FIN|RSV1|text + rsv.push_back(static_cast(0x80)); // masked, len 0 + rsv.append("\x01\x02\x03\x04", 4); + auto iss = std::istringstream{rsv}; + auto decoded = frame{}; + check_eq(read_frame(iss, decoded), frame_read::protocol_error); + + // Control payload must be ≤ 125: claim 126 via 16-bit length on ping. + auto ctrl = std::string{}; + ctrl.push_back(static_cast(0x89)); // FIN + ping + ctrl.push_back(static_cast(0xFE)); // masked + 126 + ctrl.push_back(static_cast(0x00)); + ctrl.push_back(static_cast(0x7E)); // 126 bytes + auto iss2 = std::istringstream{ctrl}; + check_eq(read_frame(iss2, decoded), frame_read::protocol_error); + }; + + tester::bdd::scenario("run_text_session rejects fragmented text with 1003, [net]") = [] { + auto frag = make_masked_text("part"sv); + frag.fin = false; + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{frame_bytes(frag)}; + run_text_session(stream, [handler_ran](std::string_view) -> std::optional { + *handler_ran = true; + return std::nullopt; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(reply.op, opcode::close); + check_eq(close_code(reply), close_unsupported_data); + check_true(not *handler_ran); + }; + + tester::bdd::scenario("run_text_session rejects continuation and binary with 1003, [net]") = [] { + auto cont = frame{}; + cont.op = opcode::continuation; + cont.fin = true; + cont.masked = true; + cont.masking_key = 0x01020304u; + + auto stream = duplex_stream{frame_bytes(cont)}; + run_text_session(stream, nullptr); + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(close_code(reply), close_unsupported_data); + + auto bin = frame{}; + bin.op = opcode::binary; + bin.masked = true; + bin.masking_key = 0x01020304u; + bin.payload = {std::byte{0x01}}; + auto stream2 = duplex_stream{frame_bytes(bin)}; + run_text_session(stream2, nullptr); + auto out2 = std::istringstream{stream2.output()}; + require_true(read_frame(out2, reply) == frame_read::ok); + check_eq(close_code(reply), close_unsupported_data); + }; + + tester::bdd::scenario("run_text_session rejects invalid UTF-8 with 1007, [net]") = [] { + auto bad = make_masked_text("x"sv); + bad.payload = {std::byte{0xFFu}}; + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{frame_bytes(bad)}; + run_text_session(stream, [handler_ran](std::string_view) -> std::optional { + *handler_ran = true; + return std::nullopt; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(close_code(reply), close_invalid_payload); + check_true(not *handler_ran); + }; + + tester::bdd::scenario("run_text_session rejects oversized frame with 1009, [net]") = [] { + // Header only: length > 1 MiB; session must close 1009 without running handler. + auto header = std::string{}; + header.push_back(static_cast(0x81)); // FIN + text + header.push_back(static_cast(0xFF)); // masked + 127 + const auto len = std::uint64_t{(1u << 20) + 1}; + for(int shift = 56; shift >= 0; shift -= 8) + header.push_back(static_cast((len >> shift) & 0xFFu)); + header.append("\x01\x02\x03\x04", 4); // masking key; no payload bytes + + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{std::move(header)}; + run_text_session(stream, [handler_ran](std::string_view) -> std::optional { + *handler_ran = true; + return std::nullopt; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(close_code(reply), close_message_too_big); + check_true(not *handler_ran); }; tester::bdd::scenario("http server websocket echo upgrade, [net]") = [] { @@ -334,13 +450,13 @@ auto register_websocket_tests() require_true(write_frame(client, make_masked_text("ping-me"sv))); auto reply = frame{}; - require_true(read_frame(client, reply)); + require_true(read_frame(client, reply) == frame_read::ok); check_eq(reply.op, opcode::text); check_eq(payload_as_string(reply), "ping-me"s); require_true(write_frame(client, make_masked_close())); auto close_reply = frame{}; - require_true(read_frame(client, close_reply)); + require_true(read_frame(client, close_reply) == frame_read::ok); check_eq(close_reply.op, opcode::close); server->stop(); diff --git a/net/net-websocket_frame.c++m b/net/net-websocket_frame.c++m index f0e5d81..f211f15 100644 --- a/net/net-websocket_frame.c++m +++ b/net/net-websocket_frame.c++m @@ -18,9 +18,22 @@ enum class opcode : std::uint8_t pong = 0xA, }; -// RFC 6455 §7.4.1 close status codes (subset used by this server). +// RFC 6455 §7.4.1 close status codes (subset used by this library). inline constexpr std::uint16_t close_normal = 1000; inline constexpr std::uint16_t close_protocol_error = 1002; +inline constexpr std::uint16_t close_unsupported_data = 1003; // e.g. fragments / binary +inline constexpr std::uint16_t close_invalid_payload = 1007; // invalid UTF-8 text +inline constexpr std::uint16_t close_message_too_big = 1009; + +inline constexpr std::uint64_t max_frame_payload = 1u << 20; // 1 MiB + +enum class frame_read : std::uint8_t +{ + ok, + io_error, // truncated header/payload or stream fail + message_too_big, // declared length > max_frame_payload + protocol_error, // RSV, reserved opcode, or control-frame rules +}; struct frame { @@ -31,6 +44,112 @@ struct frame std::vector payload; }; +inline bool is_control_opcode(opcode op) noexcept +{ + const auto v = static_cast(op); + return v >= 0x8u and v <= 0xAu; +} + +inline bool is_known_opcode(opcode op) noexcept +{ + switch(op) + { + case opcode::continuation: + case opcode::text: + case opcode::binary: + case opcode::close: + case opcode::ping: + case opcode::pong: + return true; + } + return false; +} + +inline std::uint16_t close_code(const frame& f) noexcept +{ + if(f.payload.size() < 2) + return 0; + return static_cast( + (std::to_integer(f.payload[0]) << 8) + | std::to_integer(f.payload[1])); +} + +// Strict UTF-8 (RFC 3629): reject overlongs, surrogates, and > U+10FFFF. +// No dependency on xson — WebSocket text validation stays in net. +inline bool is_valid_utf8(std::span bytes) noexcept +{ + std::size_t i = 0; + while(i < bytes.size()) + { + const auto b0 = std::to_integer(bytes[i]); + if(b0 <= 0x7Fu) + { + ++i; + continue; + } + + unsigned need = 0; + unsigned long cp = 0; + if((b0 & 0xE0u) == 0xC0u) + { + need = 1; + cp = b0 & 0x1Fu; + if(cp < 0x02u) // overlong 2-byte + return false; + } + else if((b0 & 0xF0u) == 0xE0u) + { + need = 2; + cp = b0 & 0x0Fu; + } + else if((b0 & 0xF8u) == 0xF0u) + { + need = 3; + cp = b0 & 0x07u; + if(cp > 0x04u) // would exceed U+10FFFF + return false; + } + else + return false; + + if(i + need >= bytes.size()) + return false; + + for(unsigned n = 1; n <= need; ++n) + { + const auto bx = std::to_integer(bytes[i + n]); + if((bx & 0xC0u) != 0x80u) + return false; + cp = (cp << 6) | (bx & 0x3Fu); + } + + if(need == 2) + { + if(cp < 0x800u) // overlong 3-byte + return false; + if(cp >= 0xD800u and cp <= 0xDFFFu) // UTF-16 surrogates + return false; + } + else if(need == 3) + { + if(cp < 0x10000u) // overlong 4-byte + return false; + if(cp > 0x10FFFFu) + return false; + } + + i += need + 1; + } + return true; +} + +inline bool is_valid_utf8(std::string_view text) noexcept +{ + return is_valid_utf8(std::span{ + reinterpret_cast(text.data()), + text.size()}); +} + inline void apply_mask(std::span data, std::uint32_t key) noexcept { const auto key_bytes = std::array{ @@ -129,13 +248,15 @@ inline bool write_frame(std::ostream& os, const frame& f) return static_cast(os); } -inline bool read_frame(std::istream& is, frame& f) +// Parse one frame. Structural RFC checks only (RSV, opcodes, control size/FIN, +// max length). Fragmentation / UTF-8 policy is applied by the session layer. +inline frame_read read_frame(std::istream& is, frame& f) { f = frame{}; const auto c0 = is.get(); const auto c1 = is.get(); if(not is) - return false; + return frame_read::io_error; const auto b0 = static_cast(c0); const auto b1 = static_cast(c1); @@ -144,12 +265,19 @@ inline bool read_frame(std::istream& is, frame& f) f.masked = (b1 & 0x80u) != 0; auto len = static_cast(b1 & 0x7Fu); + // §5.2: RSV1–3 must be 0 unless an extension was negotiated (we negotiate none). + if((b0 & 0x70u) != 0) + return frame_read::protocol_error; + + if(not is_known_opcode(f.op)) + return frame_read::protocol_error; + if(len == 126) { const auto hi = is.get(); const auto lo = is.get(); if(not is) - return false; + return frame_read::io_error; len = (static_cast(static_cast(hi)) << 8) | static_cast(lo); } @@ -160,15 +288,20 @@ inline bool read_frame(std::istream& is, frame& f) { const auto b = is.get(); if(not is) - return false; + return frame_read::io_error; len = (len << 8) | static_cast(b); } } - // v1 guard — reject absurd frames early - constexpr auto max_payload = std::uint64_t{1u << 20}; // 1 MiB - if(len > max_payload) - return false; + // §5.5: control frames must be FIN and ≤ 125 bytes. + if(is_control_opcode(f.op)) + { + if(not f.fin or len > 125u) + return frame_read::protocol_error; + } + + if(len > max_frame_payload) + return frame_read::message_too_big; if(f.masked) { @@ -177,7 +310,7 @@ inline bool read_frame(std::istream& is, frame& f) { const auto c = is.get(); if(not is) - return false; + return frame_read::io_error; b = static_cast(c); } f.masking_key = (static_cast(key_bytes[0]) << 24) @@ -191,13 +324,13 @@ inline bool read_frame(std::istream& is, frame& f) { is.read(reinterpret_cast(f.payload.data()), static_cast(len)); if(not is or static_cast(is.gcount()) != len) - return false; + return frame_read::io_error; } if(f.masked) apply_mask(f.payload, f.masking_key); - return true; + return frame_read::ok; } } // namespace net::websocket From ca34d0df811593c6cbd5dd006c2f8102b3092064 Mon Sep 17 00:00:00 2001 From: --global Date: Sun, 19 Jul 2026 15:16:15 +0300 Subject: [PATCH 6/7] feat: add websocket::connect client with send/recv/read_loop Provide a minimal text client matching the server spike: HTTP upgrade, masked outbound frames, automatic ping/pong, and close handshake. Co-authored-by: Cursor --- README.md | 17 ++- net/net-websocket.c++m | 230 +++++++++++++++++++++++++++++++++++++ net/net-websocket.test.c++ | 89 +++++++++++++- 3 files changed, 334 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9360e61..e85fa1f 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,10 @@ Upgrade is handled inside `http::server` (not as response middleware). Register with `server.ws(...).ws(handler)`; a matching `GET` with `Upgrade: websocket` returns `101` and runs a text-frame session (ping/pong/close + optional text replies). +Clients use `websocket::connect` for the same text session (masked outbound frames, +automatic ping/pong, `send` / `recv` / `read_loop` / `close`). No `wss://` — terminate +TLS in front of the server if needed. + v1 framing policy (fail closed with a close frame, no silent drops): - Client-to-server frames must be masked (`1002` if not). @@ -141,11 +145,22 @@ v1 framing policy (fail closed with a close frame, no silent drops): import net; import std; +// Server auto server = http::server{}; -server.ws("/echo").ws([](std::string_view msg) -> std::optional { +server.ws("/events").ws([](std::string_view msg) -> std::optional { return std::string{msg}; // echo }); server.listen("127.0.0.1", "8080"); + +// Client +auto ws = net::websocket::connect("127.0.0.1", "8080", "/events"); +ws.send("hello"); +if(auto reply = ws.recv()) +{ + // … +} +// or: ws.read_loop([](std::string_view msg) { … }); +ws.close(); ``` # Syslog Stream Example diff --git a/net/net-websocket.c++m b/net/net-websocket.c++m index 40d5736..2b22a08 100644 --- a/net/net-websocket.c++m +++ b/net/net-websocket.c++m @@ -1,5 +1,7 @@ export module net:websocket; export import :websocket_frame; +import :connector; +import :endpointstream; import :http_base64; import :http_headers; import :utils; @@ -108,6 +110,41 @@ inline std::array sha1_digest(std::string_view message) return digest; } +inline std::uint32_t random_u32() +{ + thread_local std::mt19937 gen{std::random_device{}()}; + return std::uniform_int_distribution{}(gen); +} + +inline std::string random_websocket_key() +{ + std::array bytes{}; + for(auto& b : bytes) + b = static_cast(random_u32() & 0xFFu); + return ::http::base64::encode(std::string_view{bytes.data(), bytes.size()}); +} + +inline frame mask_frame(frame f) +{ + f.masked = true; + f.masking_key = random_u32(); + return f; +} + +inline bool write_masked(std::ostream& os, frame f) +{ + return write_frame(os, mask_frame(std::move(f))); +} + +inline std::string normalize_path(std::string_view path) +{ + if(path.empty()) + return "/"s; + if(path.front() == '/') + return std::string{path}; + return "/"s + std::string{path}; +} + } // namespace detail inline std::string sec_websocket_accept(std::string_view client_key) @@ -136,6 +173,7 @@ inline bool is_websocket_upgrade(const ::http::headers& hs) } using text_handler = std::function(std::string_view)>; +using text_sink = std::function; inline void close_session(std::iostream& stream, std::uint16_t code) { @@ -221,4 +259,196 @@ inline void run_text_session(std::iostream& stream, const text_handler& on_text) } } +// Client connection: TCP + HTTP upgrade, then masked text send / unmasked recv. +// Same v1 policy as the server (text-only, no fragments, UTF-8, fail closed). +class connection +{ +public: + + connection(connection&&) noexcept = default; + connection& operator=(connection&&) noexcept = default; + + [[nodiscard]] explicit operator bool() const noexcept + { + return not m_closed and static_cast(m_stream); + } + + // Send one complete text message (client frames are masked). + [[nodiscard]] bool send(std::string_view text) + { + if(not *this) + return false; + return detail::write_masked(m_stream, make_text_frame(text)); + } + + // Next text message, or nullopt when the session ends (close / protocol / IO). + // Automatically answers ping with a masked pong. + [[nodiscard]] std::optional recv() + { + while(*this) + { + auto incoming = frame{}; + switch(read_frame(m_stream, incoming)) + { + case frame_read::ok: + break; + case frame_read::io_error: + m_closed = true; + return std::nullopt; + case frame_read::message_too_big: + fail_close(close_message_too_big); + return std::nullopt; + case frame_read::protocol_error: + fail_close(close_protocol_error); + return std::nullopt; + } + + // RFC 6455 §5.1: server-to-client frames MUST NOT be masked. + if(incoming.masked) + { + fail_close(close_protocol_error); + return std::nullopt; + } + + switch(incoming.op) + { + case opcode::ping: + if(not detail::write_masked(m_stream, make_pong_frame(incoming.payload))) + { + m_closed = true; + return std::nullopt; + } + break; + + case opcode::pong: + break; + + case opcode::close: + detail::write_masked(m_stream, make_close_frame(incoming.payload)); + m_closed = true; + return std::nullopt; + + case opcode::text: + if(not incoming.fin) + { + fail_close(close_unsupported_data); + return std::nullopt; + } + if(not is_valid_utf8(std::span{incoming.payload})) + { + fail_close(close_invalid_payload); + return std::nullopt; + } + return payload_as_string(incoming); + + case opcode::binary: + case opcode::continuation: + fail_close(close_unsupported_data); + return std::nullopt; + + default: + fail_close(close_protocol_error); + return std::nullopt; + } + } + return std::nullopt; + } + + // Call on_text for each text message until the session ends. + void read_loop(const text_sink& on_text) + { + while(auto msg = recv()) + { + if(on_text) + on_text(*msg); + } + } + + // Initiate close; best-effort wait for peer close frame. + void close(std::uint16_t code = close_normal) + { + if(m_closed) + return; + detail::write_masked(m_stream, make_close_frame(code)); + while(m_stream and not m_closed) + { + auto incoming = frame{}; + if(read_frame(m_stream, incoming) != frame_read::ok) + break; + if(incoming.op == opcode::close) + break; + if(incoming.op == opcode::ping) + detail::write_masked(m_stream, make_pong_frame(incoming.payload)); + } + m_closed = true; + m_stream.close(); + } + +private: + + friend connection connect( + std::string_view host, + std::string_view port, + std::string_view path, + const std::chrono::milliseconds& timeout); // defined below + + explicit connection(net::endpointstream stream) + : m_stream{std::move(stream)} + { + } + + void fail_close(std::uint16_t code) + { + if(not m_closed and m_stream) + detail::write_masked(m_stream, make_close_frame(code)); + m_closed = true; + } + + net::endpointstream m_stream; + bool m_closed = false; +}; + +// TCP connect + HTTP Upgrade handshake. Throws std::runtime_error on a failed +// upgrade (TCP failure throws via net::connect). +inline connection connect( + std::string_view host, + std::string_view port, + std::string_view path = "/"sv, + const std::chrono::milliseconds& timeout = net::default_connect_timeout) +{ + auto stream = net::connect(host, port, timeout); + const auto key = detail::random_websocket_key(); + const auto expected_accept = sec_websocket_accept(key); + const auto request_path = detail::normalize_path(path); + auto host_header = std::string{host}; + host_header.push_back(':'); + host_header.append(port); + + stream << "GET " << request_path << " HTTP/1.1" << net::crlf + << "Host: " << host_header << net::crlf + << "Upgrade: websocket" << net::crlf + << "Connection: Upgrade" << net::crlf + << "Sec-WebSocket-Key: " << key << net::crlf + << "Sec-WebSocket-Version: 13" << net::crlf + << net::crlf << net::flush; + + auto version = ""s; + auto status_code = ""s; + auto reason = ""s; + stream >> version >> status_code; + std::getline(stream, reason); + if(status_code != "101"s) + throw std::runtime_error{"websocket upgrade failed: expected 101, got " + status_code}; + + auto hs = ::http::headers{}; + stream >> hs >> net::crlf; + if(not is_websocket_upgrade(hs)) + throw std::runtime_error{"websocket upgrade failed: missing Upgrade headers"}; + if(not hs.contains("sec-websocket-accept") + or hs["sec-websocket-accept"] != expected_accept) + throw std::runtime_error{"websocket upgrade failed: Sec-WebSocket-Accept mismatch"}; + + return connection{std::move(stream)}; +} + } // namespace net::websocket diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ index e39c921..8628297 100644 --- a/net/net-websocket.test.c++ +++ b/net/net-websocket.test.c++ @@ -427,7 +427,7 @@ auto register_websocket_tests() require_true(bound_future.wait_for(3s) == std::future_status::ready); std::this_thread::sleep_for(50ms); - auto client = connect("127.0.0.1", "18090"); + auto client = net::connect("127.0.0.1", "18090"); client << "GET /echo HTTP/1.1" << net::crlf << "Host: 127.0.0.1:18090" << net::crlf << "Upgrade: websocket" << net::crlf @@ -464,6 +464,93 @@ auto register_websocket_tests() server_thread.join(); }; + tester::bdd::scenario("websocket::connect send/recv/close echo, [net]") = [] { + if(not network_tests_enabled()) + return; + + auto server = std::make_shared(); + server->ws("/events").ws([](std::string_view msg) -> std::optional { + return std::string{msg}; + }); + + std::promise bound; + auto bound_future = bound.get_future(); + std::thread server_thread{[server, &bound] { + try + { + server->listen("127.0.0.1"sv, "18091"sv, [&bound] { bound.set_value(); }); + } + catch(...) + { + try { bound.set_value(); } catch(...) {} + } + }}; + + using namespace std::chrono_literals; + require_true(bound_future.wait_for(3s) == std::future_status::ready); + std::this_thread::sleep_for(50ms); + + auto ws = websocket::connect("127.0.0.1"sv, "18091"sv, "/events"sv); + require_true(static_cast(ws)); + require_true(ws.send("hello"sv)); + auto reply = ws.recv(); + require_true(reply.has_value()); + check_eq(*reply, "hello"s); + ws.close(); + check_true(not ws); + + server->stop(); + if(server_thread.joinable()) + server_thread.join(); + }; + + tester::bdd::scenario("websocket::connect read_loop collects text, [net]") = [] { + if(not network_tests_enabled()) + return; + + auto server = std::make_shared(); + auto count = std::make_shared(0); + server->ws("/events").ws([count](std::string_view msg) -> std::optional { + ++(*count); + return std::string{msg} + "-" + std::to_string(*count); + }); + + std::promise bound; + auto bound_future = bound.get_future(); + std::thread server_thread{[server, &bound] { + try + { + server->listen("127.0.0.1"sv, "18092"sv, [&bound] { bound.set_value(); }); + } + catch(...) + { + try { bound.set_value(); } catch(...) {} + } + }}; + + using namespace std::chrono_literals; + require_true(bound_future.wait_for(3s) == std::future_status::ready); + std::this_thread::sleep_for(50ms); + + auto ws = websocket::connect("127.0.0.1"sv, "18092"sv, "/events"sv); + require_true(ws.send("a"sv)); + require_true(ws.send("b"sv)); + + auto seen = std::vector{}; + ws.read_loop([&](std::string_view msg) { + seen.emplace_back(msg); + if(seen.size() == 2u) + ws.close(); + }); + require_true(seen.size() == 2u); + check_eq(seen[0], "a-1"s); + check_eq(seen[1], "b-2"s); + + server->stop(); + if(server_thread.joinable()) + server_thread.join(); + }; + return true; } From 8b7582fcc174ad80fe62c8d3707b0e5613c95dc7 Mon Sep 17 00:00:00 2001 From: --global Date: Sun, 19 Jul 2026 15:22:20 +0300 Subject: [PATCH 7/7] refactor: introduce text_reply alias for websocket handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name the optional outbound payload and drop trailing return types on handler lambdas in favor of return text_reply{…}. Co-authored-by: Cursor --- README.md | 4 ++-- net/net-websocket.c++m | 4 +++- net/net-websocket.test.c++ | 40 +++++++++++++++++++------------------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e85fa1f..7cae007 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,8 @@ import std; // Server auto server = http::server{}; -server.ws("/events").ws([](std::string_view msg) -> std::optional { - return std::string{msg}; // echo +server.ws("/events").ws([](std::string_view msg) { + return net::websocket::text_reply{std::string{msg}}; // echo }); server.listen("127.0.0.1", "8080"); diff --git a/net/net-websocket.c++m b/net/net-websocket.c++m index 2b22a08..91ea694 100644 --- a/net/net-websocket.c++m +++ b/net/net-websocket.c++m @@ -172,7 +172,9 @@ inline bool is_websocket_upgrade(const ::http::headers& hs) and header_token_contains(hs["connection"], "upgrade"sv); } -using text_handler = std::function(std::string_view)>; +// nullopt = no outbound text frame; engaged = send that payload. +using text_reply = std::optional; +using text_handler = std::function; using text_sink = std::function; inline void close_session(std::iostream& stream, std::uint16_t code) diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ index 8628297..860968b 100644 --- a/net/net-websocket.test.c++ +++ b/net/net-websocket.test.c++ @@ -203,8 +203,8 @@ auto register_websocket_tests() tester::bdd::scenario("run_text_session echoes text via handler, [net]") = [] { auto stream = duplex_stream{frame_bytes(make_masked_text("hello"sv))}; - run_text_session(stream, [](std::string_view msg) -> std::optional { - return std::string{msg} + "!"; + run_text_session(stream, [](std::string_view msg) { + return text_reply{std::string{msg} + "!"}; }); auto out = std::istringstream{stream.output()}; @@ -217,8 +217,8 @@ auto register_websocket_tests() tester::bdd::scenario("run_text_session suppresses reply when handler returns nullopt, [net]") = [] { auto stream = duplex_stream{frame_bytes(make_masked_text("quiet"sv))}; - run_text_session(stream, [](std::string_view) -> std::optional { - return std::nullopt; + run_text_session(stream, [](std::string_view) { + return text_reply{}; }); check_true(stream.output().empty()); }; @@ -247,8 +247,8 @@ auto register_websocket_tests() input += frame_bytes(make_masked_text("after-close"sv)); auto stream = duplex_stream{input}; - run_text_session(stream, [](std::string_view msg) -> std::optional { - return std::string{msg}; + run_text_session(stream, [](std::string_view msg) { + return text_reply{std::string{msg}}; }); auto out = std::istringstream{stream.output()}; @@ -265,9 +265,9 @@ auto register_websocket_tests() // be rejected with a protocol-error close and the handler must not run. auto handler_ran = std::make_shared(false); auto stream = duplex_stream{frame_bytes(make_text_frame("nomask"sv))}; - run_text_session(stream, [handler_ran](std::string_view msg) -> std::optional { + run_text_session(stream, [handler_ran](std::string_view msg) { *handler_ran = true; - return std::string{msg}; + return text_reply{std::string{msg}}; }); auto out = std::istringstream{stream.output()}; @@ -321,9 +321,9 @@ auto register_websocket_tests() frag.fin = false; auto handler_ran = std::make_shared(false); auto stream = duplex_stream{frame_bytes(frag)}; - run_text_session(stream, [handler_ran](std::string_view) -> std::optional { + run_text_session(stream, [handler_ran](std::string_view) { *handler_ran = true; - return std::nullopt; + return text_reply{}; }); auto out = std::istringstream{stream.output()}; @@ -365,9 +365,9 @@ auto register_websocket_tests() bad.payload = {std::byte{0xFFu}}; auto handler_ran = std::make_shared(false); auto stream = duplex_stream{frame_bytes(bad)}; - run_text_session(stream, [handler_ran](std::string_view) -> std::optional { + run_text_session(stream, [handler_ran](std::string_view) { *handler_ran = true; - return std::nullopt; + return text_reply{}; }); auto out = std::istringstream{stream.output()}; @@ -389,9 +389,9 @@ auto register_websocket_tests() auto handler_ran = std::make_shared(false); auto stream = duplex_stream{std::move(header)}; - run_text_session(stream, [handler_ran](std::string_view) -> std::optional { + run_text_session(stream, [handler_ran](std::string_view) { *handler_ran = true; - return std::nullopt; + return text_reply{}; }); auto out = std::istringstream{stream.output()}; @@ -406,8 +406,8 @@ auto register_websocket_tests() return; auto server = std::make_shared(); - server->ws("/echo").ws([](std::string_view msg) -> std::optional { - return std::string{msg}; + server->ws("/echo").ws([](std::string_view msg) { + return text_reply{std::string{msg}}; }); std::promise bound; @@ -469,8 +469,8 @@ auto register_websocket_tests() return; auto server = std::make_shared(); - server->ws("/events").ws([](std::string_view msg) -> std::optional { - return std::string{msg}; + server->ws("/events").ws([](std::string_view msg) { + return text_reply{std::string{msg}}; }); std::promise bound; @@ -510,9 +510,9 @@ auto register_websocket_tests() auto server = std::make_shared(); auto count = std::make_shared(0); - server->ws("/events").ws([count](std::string_view msg) -> std::optional { + server->ws("/events").ws([count](std::string_view msg) { ++(*count); - return std::string{msg} + "-" + std::to_string(*count); + return text_reply{std::string{msg} + "-" + std::to_string(*count)}; }); std::promise bound;