cheatah
Source

stdlib/tests/websocket_test.cpp

1// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).
2// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.
3// websocket_test — offline unit checks for the websocket client. The framing
4// and live round-trip are covered by the system tests (a real WSS server);
5// here we cover the input validation that needs no network.
7#include <cstdint>
8#include <stdexcept>
9#include <string>
10#include <thread>
12#include <gtest/gtest.h>
14#include "socket.hpp"
15#include "websocket.hpp"
16#include "websocket_lowlevel.hpp" // recv()/close() + the CHEATAH_WEBSOCKET_TESTING frame-parser seam
18namespace ws = cheatah::websocket;
19namespace sk = cheatah::socket;
21namespace {
23// Build a raw (unmasked, server-style) WebSocket frame: FIN|opcode, a length encoding, payload.
24std::string frame(unsigned char b0, const std::string& payload) {
25 std::string f(1, static_cast<char>(b0));
26 const std::uint64_t n = payload.size();
27 if (n < 126) {
28 f.push_back(static_cast<char>(n));
29 } else {
30 f.push_back(static_cast<char>(126));
31 f.push_back(static_cast<char>((n >> 8) & 0xFF));
32 f.push_back(static_cast<char>(n & 0xFF));
33 }
34 return f + payload;
37// A frame HEADER that DECLARES a length via the 64-bit field but carries no payload — the shape a
38// malicious server uses to overflow `header + len`. The cap must reject it before any read/copy.
39std::string len64_header(unsigned char b0, std::uint64_t declared) {
40 std::string f(1, static_cast<char>(b0));
41 f.push_back(static_cast<char>(127));
42 for (int sh = 56; sh >= 0; sh -= 8) f.push_back(static_cast<char>((declared >> sh) & 0xFF));
43 return f;
46// A control-frame header declaring a length via the 16-bit field, no payload.
47std::string len16_header(unsigned char b0, std::uint64_t declared) {
48 std::string f(1, static_cast<char>(b0));
49 f.push_back(static_cast<char>(126));
50 f.push_back(static_cast<char>((declared >> 8) & 0xFF));
51 f.push_back(static_cast<char>(declared & 0xFF));
52 return f;
55// Drive recv() over a synthetic session pre-loaded with `bytes` (frame-parser fuzzing seam), then
56// free it. Returns recv()'s result; rethrows whatever recv throws (after freeing the session).
57std::string recv_bytes(const std::string& bytes, std::uint64_t max_frame = 0,
58 std::uint64_t max_message = 0) {
59 const long long h = ws::testonly::session_from_bytes(bytes, max_frame, max_message);
60 std::string out;
61 try {
62 out = ws::recv(h);
63 } catch (...) {
64 ws::close(h);
65 throw;
66 }
67 ws::close(h);
68 return out;
71} // namespace
73// === Red-team: a malicious server must not corrupt memory, OOM, or bypass RFC 6455 framing. ===
75// F1 (CRITICAL): a 64-bit length near 2^64 must be rejected BEFORE it overflows `header + len`
76// and drives an out-of-bounds unmask/copy. This is the memory-corruption bug.
77TEST(CheatahWebSocket, RejectsOverflowingFrameLength) {
78 // opcode 0x2 (binary), FIN; declared length = 0xFFFFFFFFFFFFFFFF (would wrap header+len).
79 EXPECT_THROW(recv_bytes(len64_header(0x82, 0xFFFFFFFFFFFFFFFFull)), std::runtime_error);
80 // A merely-huge (non-wrapping) length is rejected the same way (would otherwise OOM).
81 EXPECT_THROW(recv_bytes(len64_header(0x82, 8ull << 30)), std::runtime_error); // 8 GiB
82 // Even the MASKED path (the actual out-of-bounds-write trigger) is refused before unmasking.
83 std::string masked = len64_header(0x82, 1ull << 40);
84 masked[1] = static_cast<char>(0x80 | 127); // set the MASK bit in b1
85 EXPECT_THROW(recv_bytes(masked), std::runtime_error);
88// F1: a frame just over the (here-tiny) cap is rejected; one at the cap is accepted.
89TEST(CheatahWebSocket, FramePayloadCapEnforced) {
90 EXPECT_THROW(recv_bytes(frame(0x82, std::string(101, 'x')), /*max_frame=*/100), std::runtime_error);
91 EXPECT_EQ(recv_bytes(frame(0x82, std::string(100, 'x')), /*max_frame=*/100), std::string(100, 'x'));
94// F2 (OOM): a fragmented message that would exceed the reassembly cap is rejected.
95TEST(CheatahWebSocket, ReassembledMessageCapEnforced) {
96 // First fragment (text, FIN=0) of 5 bytes, then a continuation of 5 more; cap = 8 < 10.
97 const std::string frames = frame(0x01, "aaaaa") + frame(0x80, "bbbbb");
98 EXPECT_THROW(recv_bytes(frames, /*max_frame=*/0, /*max_message=*/8), std::runtime_error);
101// F3: control frames must be <=125 bytes and MUST NOT be fragmented (RFC 6455 §5.5).
102TEST(CheatahWebSocket, RejectsOversizedControlFrame) {
103 EXPECT_THROW(recv_bytes(len16_header(0x89, 200)), std::runtime_error); // ping, 200 bytes
105TEST(CheatahWebSocket, RejectsFragmentedControlFrame) {
106 EXPECT_THROW(recv_bytes(frame(0x09, "abc")), std::runtime_error); // ping, FIN=0
109// F4: reserved bits set (no extension negotiated) and undefined opcodes fail the connection.
110TEST(CheatahWebSocket, RejectsReservedBits) {
111 EXPECT_THROW(recv_bytes(frame(0xC1, "")), std::runtime_error); // RSV1 | text | FIN
113TEST(CheatahWebSocket, RejectsUnknownOpcode) {
114 EXPECT_THROW(recv_bytes(frame(0x83, "")), std::runtime_error); // opcode 0x3 (undefined)
117// Fragmentation state machine: a stray continuation, or a new data frame mid-message, is invalid.
118TEST(CheatahWebSocket, RejectsContinuationWithNoMessage) {
119 EXPECT_THROW(recv_bytes(frame(0x80, "x")), std::runtime_error); // continuation, FIN, no msg
121TEST(CheatahWebSocket, RejectsNewDataFrameDuringFragment) {
122 const std::string frames = frame(0x01, "ab") + frame(0x81, "cd"); // text(FIN=0) then text(FIN)
123 EXPECT_THROW(recv_bytes(frames), std::runtime_error);
126// === Blue-team: the valid paths still work (single frame, fragmentation, an interleaved pong). ===
127TEST(CheatahWebSocket, AcceptsValidSingleFrame) {
128 EXPECT_EQ(recv_bytes(frame(0x81, "hello")), "hello"); // text, FIN
129 const std::string bin("\x00\x01\x02", 3); // NUL-containing binary payload
130 EXPECT_EQ(recv_bytes(frame(0x82, bin)), bin); // binary, FIN — byte-safe
132TEST(CheatahWebSocket, AcceptsFragmentedMessage) {
133 const std::string frames = frame(0x01, "he") + frame(0x00, "l") + frame(0x80, "lo");
134 EXPECT_EQ(recv_bytes(frames), "hello");
136TEST(CheatahWebSocket, SkipsPongThenReturnsData) {
137 const std::string frames = frame(0x8A, "") + frame(0x81, "ok"); // pong (ignored), then text
138 EXPECT_EQ(recv_bytes(frames), "ok");
141TEST(CheatahWebSocket, ConnectUrlRejectsNonWss) {
142 // Only wss:// is supported; a non-wss scheme fails fast, before any socket work. open_url()
143 // is the cheatah-facing guard factory (it delegates to the C++-only connect_url()).
144 EXPECT_THROW(ws::open_url("ws://example.com/"), std::runtime_error);
145 EXPECT_THROW(ws::open_url("https://example.com/"), std::runtime_error);
146 EXPECT_THROW(ws::open_url("example.com"), std::runtime_error);
149// A default-constructed Client owns nothing: closed, id() == 0, and close() reports -1 without
150// touching a session. open_url() on a non-wss scheme throws before a session is ever created,
151// so the guard is never left holding a bogus handle. (Functional send/recv/close over a live
152// session are covered by WebSocketSys.EchoRoundTrip against a real wss peer.)
153TEST(CheatahWebSocket, ClientDefaultIsClosed) {
154 ws::Client c;
155 EXPECT_FALSE(c.is_open());
156 EXPECT_EQ(c.id(), 0);
157 EXPECT_EQ(c.close(), -1); // nothing to close
158 EXPECT_THROW(ws::open_url("ws://example.com/"), std::runtime_error);
161// ---- plaintext ws:// -------------------------------------------------------------------
162// The client is TLS-only by default and gained a PLAINTEXT mode for one reason: Chrome's
163// DevTools endpoint speaks ws:// on loopback and offers no TLS at all. These pin the two
164// properties that keep "insecure" from becoming reachable by accident.
166// The guard. Plaintext to anything that is not this machine is refused before a socket is
167// even opened, so no configuration turns this into a cleartext WebSocket to the internet.
168TEST(WebSocketPlaintext, RefusesNonLoopbackHost) {
169 try {
170 ws::connect(std::string("example.com"), 80, std::string("/"), std::string("example.com"),
171 false, std::string(""), /*secure=*/false);
172 FAIL() << "plaintext to a non-loopback host must be refused";
173 } catch (const std::runtime_error& e) {
174 const std::string msg = e.what();
175 EXPECT_NE(msg.find("loopback"), std::string::npos) << msg;
176 EXPECT_NE(msg.find("wss://"), std::string::npos) << msg; // says what to use instead
177 }
180TEST(WebSocketPlaintext, LoopbackSpellingsAreAllAccepted) {
181 // Refused for a reason OTHER than the loopback guard: nothing is listening, so this gets
182 // as far as the TCP connect. That is the point — the guard let it through.
183 for (const char* host : {"127.0.0.1", "::1", "localhost"}) {
184 try {
185 ws::connect(std::string(host), 1, std::string("/"), std::string(host), false,
186 std::string(""), /*secure=*/false);
187 FAIL() << "port 1 should not have accepted a connection";
188 } catch (const std::runtime_error& e) {
189 const std::string msg = e.what();
190 EXPECT_EQ(msg.find("loopback"), std::string::npos)
191 << host << " was refused by the loopback guard: " << msg;
192 }
193 }
196// The plaintext path end to end as far as it can go without a WebSocket server: a real TCP
197// peer on loopback that accepts and closes. This is what exercises the skip-TLS branch and
198// the upgrade exchange over socket:: rather than tls::.
199TEST(WebSocketPlaintext, ConnectsOverPlainTcpAndReportsTheUpgradeFailure) {
200 const long long lfd = sk::tcp_listen("127.0.0.1", 0, 1);
201 ASSERT_GE(lfd, 0);
202 const long long port = sk::local_port(lfd);
203 ASSERT_GT(port, 0);
205 // Accept, read whatever arrives, then close without answering the upgrade.
206 std::thread server([&] {
207 const long long conn = sk::accept(lfd);
208 if (conn >= 0) {
209 sk::recv(conn, 4096);
210 sk::close(conn);
211 }
212 });
214 std::string what;
215 try {
216 ws::connect(std::string("127.0.0.1"), port, std::string("/"), std::string("127.0.0.1"),
217 false, std::string(""), /*secure=*/false);
218 ADD_FAILURE() << "a peer that never answers the upgrade must not yield a session";
219 } catch (const std::runtime_error& e) {
220 what = e.what();
221 }
222 server.join();
223 sk::close(lfd);
225 // Either the request could not be sent or the peer closed mid-upgrade; both are the
226 // plaintext transport reporting a real failure rather than a TLS one.
227 EXPECT_FALSE(what.empty());
228 EXPECT_EQ(what.find("TLS"), std::string::npos) << "plaintext must not report a TLS error: " << what;
231// The upgrade-request SEND failure. connect() cannot reach this without racing a peer reset,
232// so it is driven through the white-box seam: an invalid descriptor makes socket::send fail
233// with EBADF every time. Pins that the failure is reported as an upgrade failure (not a TLS
234// one) and that the session is destroyed on the way out rather than leaked.
235TEST(WebSocketPlaintext, UpgradeSendFailureIsReported) {
236 try {
237 ws::testonly::send_upgrade_on_closed_fd();
238 FAIL() << "sending an upgrade on a closed descriptor must throw";
239 } catch (const std::runtime_error& e) {
240 const std::string msg = e.what();
241 EXPECT_NE(msg.find("upgrade request failed"), std::string::npos) << msg;
242 EXPECT_EQ(msg.find("TLS"), std::string::npos)
243 << "a plaintext session must not report a TLS error: " << msg;
244 }