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 framing4
// 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 seam18
namespace ws = cheatah::websocket;19
namespace sk = cheatah::socket;21
namespace {23
// Build a raw (unmasked, server-style) WebSocket frame: FIN|opcode, a length encoding, payload.24
std::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;35
}37
// A frame HEADER that DECLARES a length via the 64-bit field but carries no payload — the shape a38
// malicious server uses to overflow `header + len`. The cap must reject it before any read/copy.39
std::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;44
}46
// A control-frame header declaring a length via the 16-bit field, no payload.47
std::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;53
}55
// Drive recv() over a synthetic session pre-loaded with `bytes` (frame-parser fuzzing seam), then56
// free it. Returns recv()'s result; rethrows whatever recv throws (after freeing the session).57
std::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;69
}71
} // namespace73
// === 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.77
TEST(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 GiB82
// 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 b185
EXPECT_THROW(recv_bytes(masked), std::runtime_error);86
}88
// F1: a frame just over the (here-tiny) cap is rejected; one at the cap is accepted.89
TEST(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'));92
}94
// F2 (OOM): a fragmented message that would exceed the reassembly cap is rejected.95
TEST(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);99
}101
// F3: control frames must be <=125 bytes and MUST NOT be fragmented (RFC 6455 §5.5).102
TEST(CheatahWebSocket, RejectsOversizedControlFrame) {103
EXPECT_THROW(recv_bytes(len16_header(0x89, 200)), std::runtime_error); // ping, 200 bytes104
}105
TEST(CheatahWebSocket, RejectsFragmentedControlFrame) {106
EXPECT_THROW(recv_bytes(frame(0x09, "abc")), std::runtime_error); // ping, FIN=0107
}109
// F4: reserved bits set (no extension negotiated) and undefined opcodes fail the connection.110
TEST(CheatahWebSocket, RejectsReservedBits) {111
EXPECT_THROW(recv_bytes(frame(0xC1, "")), std::runtime_error); // RSV1 | text | FIN112
}113
TEST(CheatahWebSocket, RejectsUnknownOpcode) {114
EXPECT_THROW(recv_bytes(frame(0x83, "")), std::runtime_error); // opcode 0x3 (undefined)115
}117
// Fragmentation state machine: a stray continuation, or a new data frame mid-message, is invalid.118
TEST(CheatahWebSocket, RejectsContinuationWithNoMessage) {119
EXPECT_THROW(recv_bytes(frame(0x80, "x")), std::runtime_error); // continuation, FIN, no msg120
}121
TEST(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);124
}126
// === Blue-team: the valid paths still work (single frame, fragmentation, an interleaved pong). ===127
TEST(CheatahWebSocket, AcceptsValidSingleFrame) {128
EXPECT_EQ(recv_bytes(frame(0x81, "hello")), "hello"); // text, FIN129
const std::string bin("\x00\x01\x02", 3); // NUL-containing binary payload130
EXPECT_EQ(recv_bytes(frame(0x82, bin)), bin); // binary, FIN — byte-safe131
}132
TEST(CheatahWebSocket, AcceptsFragmentedMessage) {133
const std::string frames = frame(0x01, "he") + frame(0x00, "l") + frame(0x80, "lo");134
EXPECT_EQ(recv_bytes(frames), "hello");135
}136
TEST(CheatahWebSocket, SkipsPongThenReturnsData) {137
const std::string frames = frame(0x8A, "") + frame(0x81, "ok"); // pong (ignored), then text138
EXPECT_EQ(recv_bytes(frames), "ok");139
}141
TEST(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);147
}149
// A default-constructed Client owns nothing: closed, id() == 0, and close() reports -1 without150
// 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 live152
// session are covered by WebSocketSys.EchoRoundTrip against a real wss peer.)153
TEST(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 close158
EXPECT_THROW(ws::open_url("ws://example.com/"), std::runtime_error);159
}161
// ---- plaintext ws:// -------------------------------------------------------------------162
// The client is TLS-only by default and gained a PLAINTEXT mode for one reason: Chrome's163
// DevTools endpoint speaks ws:// on loopback and offers no TLS at all. These pin the two164
// 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 is167
// even opened, so no configuration turns this into a cleartext WebSocket to the internet.168
TEST(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 instead177
}178
}180
TEST(WebSocketPlaintext, LoopbackSpellingsAreAllAccepted) {181
// Refused for a reason OTHER than the loopback guard: nothing is listening, so this gets182
// 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
}194
}196
// The plaintext path end to end as far as it can go without a WebSocket server: a real TCP197
// peer on loopback that accepts and closes. This is what exercises the skip-TLS branch and198
// the upgrade exchange over socket:: rather than tls::.199
TEST(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 the226
// 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;229
}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 fail233
// with EBADF every time. Pins that the failure is reported as an upgrade failure (not a TLS234
// one) and that the session is destroyed on the way out rather than leaked.235
TEST(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
}245
}247
// The plaintext (ws://) transport had two defects, both invisible to the wss:// tests:248
// * shutdown() returned -1 for ANY plaintext session — its own socket::shutdown branch was249
// unreachable behind a `tls < 0` guard that had already returned — so a reader blocked in250
// recv() could never be woken, and a valid session reported an error.251
// * close() skipped the RFC 6455 close frame unless the session was TLS.252
// Both are exercised here over a real loopback socket.253
TEST(CheatahWebSocket, PlaintextShutdownAndCloseUseTheSocket) {254
namespace sock = cheatah::socket;255
const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);256
ASSERT_GE(listen_fd, 0);257
const long long port = sock::local_port(listen_fd);259
long long peer = -1;260
std::thread accepter([&] { peer = sock::accept(listen_fd); });261
const long long client = sock::tcp_connect("127.0.0.1", port);262
accepter.join();263
ASSERT_GE(client, 0);264
ASSERT_GE(peer, 0);266
// shutdown() must reach socket::shutdown and SUCCEED — it used to return -1 unconditionally.267
const long long s = ws::testonly::plaintext_session_on_fd(client);268
EXPECT_EQ(ws::shutdown(s), 0) << "a plaintext session must be wakeable";269
EXPECT_EQ(ws::close(s), 0);271
// close() on a live plaintext session writes the close frame (opcode 0x8, client-masked,272
// so the 2-byte header carries the mask bit and a zero-length payload).273
long long peer2 = -1;274
std::thread accepter2([&] { peer2 = sock::accept(listen_fd); });275
const long long client2 = sock::tcp_connect("127.0.0.1", port);276
accepter2.join();277
ASSERT_GE(client2, 0);278
ASSERT_GE(peer2, 0);279
const long long s2 = ws::testonly::plaintext_session_on_fd(client2);280
EXPECT_EQ(ws::close(s2), 0);281
const std::string frame = sock::recv(peer2, 8);282
ASSERT_GE(frame.size(), 2U) << "close() sent no frame over the plaintext transport";283
EXPECT_EQ(static_cast<unsigned char>(frame[0]) & 0x0FU, 0x08U) << "not a close frame";285
sock::close(peer);286
sock::close(peer2);287
sock::close(listen_fd);288
}