Skip to content
Open
113 changes: 97 additions & 16 deletions src/WebSocketContext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,16 @@ void WebSocketContext::run() {
if (!_cfg.tls.disableHostnameValidation) {
X509_VERIFY_PARAM* param = SSL_get0_param(ssl);
if (param) {
int ret = X509_VERIFY_PARAM_set1_host(param, _cfg.host.c_str(), 0); // No port matching
// For an IP-literal endpoint the peer identity must be checked
// against the certificate's iPAddress SANs (set1_ip), not its
// dNSName SANs (set1_host); using set1_host on an IP would mis-
// validate. Hostnames use set1_host.
int ret = _cfg.is_ip_address
? X509_VERIFY_PARAM_set1_ip_asc(param, _cfg.host.c_str())
: X509_VERIFY_PARAM_set1_host(param, _cfg.host.c_str(), 0); // No port matching
if (ret != 1) {
log_error("Failed to set hostname for verification");
log_error("Failed to set %s for verification",
_cfg.is_ip_address ? "IP address" : "hostname");
sendError(ErrorCode::TLS_INIT_FAILED, "Failed hostname verification setup");
SSL_free(ssl);
_tls.reset();
Expand Down Expand Up @@ -346,7 +353,21 @@ void WebSocketContext::timeoutCallback(evutil_socket_t /*fd*/, short /*event*/,

void WebSocketContext::pingCallback(evutil_socket_t /*fd*/, short /*event*/, void *arg) {
auto* self = static_cast<WebSocketContext*>(arg);
// Heartbeat only runs after the upgrade; sendPing() is a no-op before then.
if (!self->upgraded.load(std::memory_order_acquire)) return;

// If earlier pings have gone unanswered for MAX_MISSED_PONGS intervals the
// peer is half-open (TCP up, no application response). Declare the
// connection dead, mirroring the fatal-error teardown in handleEvent.
if (self->pings_outstanding >= MAX_MISSED_PONGS) {
log_error("ping timeout: %d unanswered ping(s)", self->pings_outstanding);
self->sendError(ErrorCode::PING_TIMEOUT, "Ping timeout (no pong)");
self->connection_state.store(ConnectionState::DISCONNECTING, std::memory_order_release);
self->requestLoopExit();
return;
}
self->sendPing();
self->pings_outstanding++;
}

void WebSocketContext::wakeupCallback(evutil_socket_t, short, void* arg) {
Expand Down Expand Up @@ -619,13 +640,18 @@ void WebSocketContext::handleRead(bufferevent* bev) {

if (!upgraded.load()) {

// Cap the pre-upgrade handshake response. Without this a server could
// stream header bytes forever (never sending the terminating CRLFCRLF),
// growing this buffer and re-scanning it from the start on every read.
static constexpr size_t MAX_HANDSHAKE_BYTES = 64u * 1024u;

const size_t len = evbuffer_get_length(input);
if (len < 4) return;

std::vector<char> snap(len);
evbuffer_copyout(input, snap.data(), len);
const char* b = snap.data();

// Find end of headers: "\r\n\r\n" (length-bounded)
size_t headerBytes = 0;
for (size_t i = 0; i + 3 < len; ++i) {
Expand All @@ -634,15 +660,47 @@ void WebSocketContext::handleRead(bufferevent* bev) {
break;
}
}
if (headerBytes == 0) return;
if (headerBytes == 0) {
if (len > MAX_HANDSHAKE_BYTES) {
log_error("handshake response exceeded %zu bytes without header terminator",
static_cast<size_t>(MAX_HANDSHAKE_BYTES));
connection_state.store(ConnectionState::FAILED, std::memory_order_release);
sendError(ErrorCode::CONNECT_FAILED, "handshake response too large");
evbuffer_drain(input, len);
requestLoopExit();
}
return; // wait for more header bytes
}
std::string resp(b, headerBytes);

//log_debug("RESP: %s", resp.c_str());

if (resp.find("HTTP/1.1 101", 0) == std::string::npos ||
!containsHeader(resp, "Sec-WebSocket-Accept:"))
// RFC 6455 §4.1: the client MUST fail the connection unless the response
// is 101 AND Sec-WebSocket-Accept equals base64(SHA1(key + GUID)).
// Accepting on mere header presence would let any 101-returning endpoint
// (a stray HTTP responder, a cache, an off-path injector) masquerade as a
// valid WebSocket peer. `accept` was computed from our nonce at construction.
auto headerValue = [&resp](const char* lowerName) -> std::string {
std::string lower = resp;
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
const size_t p = lower.find(lowerName);
if (p == std::string::npos) return std::string();
const size_t vstart = p + std::char_traits<char>::length(lowerName);
size_t lineEnd = resp.find("\r\n", vstart);
if (lineEnd == std::string::npos) lineEnd = resp.size();
// Value from the original-case buffer (base64 is case-sensitive), trimmed.
const size_t a = resp.find_first_not_of(" \t", vstart);
if (a == std::string::npos || a >= lineEnd) return std::string();
size_t b = lineEnd;
while (b > a && (resp[b - 1] == ' ' || resp[b - 1] == '\t' || resp[b - 1] == '\r')) --b;
return resp.substr(a, b - a);
};

const bool is101 = resp.find("HTTP/1.1 101", 0) != std::string::npos;
const std::string acceptValue = headerValue("sec-websocket-accept:");
if (!is101 || acceptValue.empty() || acceptValue != accept)
{
log_error("WebSocket upgrade failed");
log_error("WebSocket upgrade failed (status/accept mismatch)");
connection_state.store(ConnectionState::FAILED, std::memory_order_release);
sendError(ErrorCode::CONNECT_FAILED, "WebSocket upgrade failed");
evbuffer_drain(input, len);
Expand Down Expand Up @@ -795,28 +853,45 @@ void WebSocketContext::flushSendQueue() {
}
}

// Strip CR/LF/NUL from a value interpolated into a request line, so a
// configured URI/host/header cannot inject additional handshake headers or
// smuggle a second request (header/request splitting).
static std::string stripCRLF(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c != '\r' && c != '\n' && c != '\0') out.push_back(c);
}
return out;
}

void WebSocketContext::sendHandshakeRequest() {
if (!_bev) return;
log_debug("Sending WebSocket handshake request");

auto out = bufferevent_get_output(_bev);

evbuffer_add_printf(out, "GET %s HTTP/1.1\r\n", _cfg.uri.c_str());
evbuffer_add_printf(out, "Host:%s:%d\r\n", _cfg.host.c_str(), _cfg.port);
const std::string uri = stripCRLF(_cfg.uri);
const std::string host = stripCRLF(_cfg.host);

evbuffer_add_printf(out, "GET %s HTTP/1.1\r\n", uri.c_str());
evbuffer_add_printf(out, "Host:%s:%d\r\n", host.c_str(), _cfg.port);
evbuffer_add_printf(out, "Upgrade:websocket\r\n");
evbuffer_add_printf(out, "Connection:upgrade\r\n");
evbuffer_add_printf(out, "Sec-WebSocket-Key:%s\r\n", key.c_str());
evbuffer_add_printf(out, "Sec-WebSocket-Version:13\r\n");

if (_cfg.compression_requested) {
evbuffer_add_printf(out, "Sec-WebSocket-Extensions:permessage-deflate; client_no_context_takeover; server_no_context_takeover; client_max_window_bits=9\r\n");
}

evbuffer_add_printf(out, "Origin:http://%s:%d\r\n", _cfg.host.c_str(), _cfg.port);
evbuffer_add_printf(out, "Origin:http://%s:%d\r\n", host.c_str(), _cfg.port);

if (!_cfg.headers.headers.empty()) {
for (const auto& header : _cfg.headers.headers) {
evbuffer_add_printf(out, "%s:%s\r\n", header.first.c_str(), header.second.c_str());
evbuffer_add_printf(out, "%s:%s\r\n",
stripCRLF(header.first).c_str(),
stripCRLF(header.second).c_str());
}
}

Expand Down Expand Up @@ -1050,9 +1125,13 @@ void WebSocketContext::send(evbuffer* buf, const void* raw_data, size_t raw_len,

thread_local uint32_t s = 0;
if (s == 0) {
uint64_t t = static_cast<uint64_t>(time(nullptr));
uintptr_t a = reinterpret_cast<uintptr_t>(&s);
s = static_cast<uint32_t>((t ^ (t >> 32) ^ a) | 1u);
// RFC 6455 §5.3: the masking key must be unpredictable. Seed the
// per-frame PRNG from a strong entropy source (as getWebSocketKey does
// for the handshake nonce) rather than time()+stack-address, which is
// guessable. The splitmix step below then produces per-frame masks
// without a syscall per frame.
std::random_device rd;
s = (static_cast<uint32_t>(rd()) ^ static_cast<uint32_t>(rd())) | 1u;
}

auto next_u32 = [&]() -> uint32_t {
Expand Down Expand Up @@ -1171,6 +1250,8 @@ bool WebSocketContext::rxCompressionEnabled() const {
void WebSocketContext::onRxPong(std::vector<uint8_t>&& payload) {
log_debug("Received pong frame (%zu bytes)", payload.size());
(void)payload;
// Peer is alive; reset the heartbeat liveness counter.
pings_outstanding = 0;
}

void WebSocketContext::onRxPing(std::vector<uint8_t>&& payload) {
Expand Down
7 changes: 7 additions & 0 deletions src/WebSocketContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ class WebSocketContext: public std::enable_shared_from_this<WebSocketContext>,
struct event *ping_event = nullptr;
struct event *wakeup_event = nullptr;

// Heartbeat liveness: number of pings sent since the last pong. Touched only
// on the event thread (pingCallback / onRxPong). The connection is declared
// dead once this reaches MAX_MISSED_PONGS, detecting a half-open peer that
// stopped responding while TCP stayed up.
int pings_outstanding = 0;
static constexpr int MAX_MISSED_PONGS = 2;

// Sender
struct event *send_event = nullptr;
std::atomic_bool send_flush_pending{false};
Expand Down
28 changes: 28 additions & 0 deletions src/WebSocketReceiver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@ bool WebSocketReceiver::rxInflate(const uint8_t* in, size_t in_len, std::vector<

if (produced) {
const size_t old = out.size();
if (produced > MAX_MESSAGE_SIZE - old) {
log_error("permessage-deflate output exceeds %zu bytes; aborting (possible decompression bomb)",
static_cast<size_t>(MAX_MESSAGE_SIZE));
return false;
}
out.resize(old + produced);
std::memcpy(out.data() + old, tmp, produced);
}
Expand Down Expand Up @@ -330,6 +335,14 @@ void WebSocketReceiver::onData(evbuffer* buf) {
return;
}

// RFC 7692 §6: the per-message-deflate RSV1 bit may only appear on the
// first frame of a message. It is illegal on control frames and on
// continuation frames even when compression is negotiated.
if (rsv1 && ((opcode & 0x08) != 0 || opcode == 0x00)) {
_sinks.onRxProtocolError(1002, "RSV1 set on control or continuation frame");
return;
}

if ((opcode & 0x08) != 0 && !fin) {
_sinks.onRxProtocolError(1002, "Control frame fragmented");
return;
Expand Down Expand Up @@ -359,6 +372,14 @@ void WebSocketReceiver::onData(evbuffer* buf) {
return;
}

// Bound a single data/continuation frame so a peer-declared 64-bit length
// cannot make us buffer (and reserve/copy) unbounded memory before the
// frame is even complete. Control frames are already capped at 125 above.
if ((opcode & 0x08) == 0 && payload_len > MAX_MESSAGE_SIZE) {
_sinks.onRxProtocolError(1009, "Frame payload too large");
return;
}

const size_t need = header_len + static_cast<size_t>(payload_len);
if (data_len < need) break; // wait for full frame

Expand Down Expand Up @@ -407,6 +428,13 @@ void WebSocketReceiver::handleContinuationFrame(const unsigned char* payload, si
return;
}

if (payload_len > MAX_MESSAGE_SIZE - fragmented_message.size()) {
log_error("reassembled message exceeds %zu bytes; aborting",
static_cast<size_t>(MAX_MESSAGE_SIZE));
_sinks.onRxProtocolError(1009, "Message too large");
return;
}

fragmented_message.insert(fragmented_message.end(),
payload,
payload + payload_len);
Expand Down
6 changes: 6 additions & 0 deletions src/WebSocketReceiver.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,10 @@ class WebSocketReceiver {

Utf8Validator utf8Validator;
bool isValidUtf8(const char *str, size_t len);

// Upper bound on a fully reassembled / inflated message. permessage-deflate
// lets a tiny frame expand without limit (a decompression bomb), so the
// inflate output is capped here. Audio-stream control/media messages are a
// few KB; 16 MiB is generous headroom while keeping memory bounded.
static constexpr size_t MAX_MESSAGE_SIZE = 16u * 1024u * 1024u;
};