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
}