diff --git a/src/WebSocketContext.cpp b/src/WebSocketContext.cpp index 2147773..a44cdd7 100644 --- a/src/WebSocketContext.cpp +++ b/src/WebSocketContext.cpp @@ -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(); @@ -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(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) { @@ -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 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) { @@ -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(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::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); @@ -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()); } } @@ -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(time(nullptr)); - uintptr_t a = reinterpret_cast(&s); - s = static_cast((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(rd()) ^ static_cast(rd())) | 1u; } auto next_u32 = [&]() -> uint32_t { @@ -1171,6 +1250,8 @@ bool WebSocketContext::rxCompressionEnabled() const { void WebSocketContext::onRxPong(std::vector&& 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&& payload) { diff --git a/src/WebSocketContext.h b/src/WebSocketContext.h index 1a97dae..0a79664 100644 --- a/src/WebSocketContext.h +++ b/src/WebSocketContext.h @@ -219,6 +219,13 @@ class WebSocketContext: public std::enable_shared_from_this, 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}; diff --git a/src/WebSocketReceiver.cpp b/src/WebSocketReceiver.cpp index 7ca81bf..48e22bf 100644 --- a/src/WebSocketReceiver.cpp +++ b/src/WebSocketReceiver.cpp @@ -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(MAX_MESSAGE_SIZE)); + return false; + } out.resize(old + produced); std::memcpy(out.data() + old, tmp, produced); } @@ -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; @@ -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(payload_len); if (data_len < need) break; // wait for full frame @@ -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(MAX_MESSAGE_SIZE)); + _sinks.onRxProtocolError(1009, "Message too large"); + return; + } + fragmented_message.insert(fragmented_message.end(), payload, payload + payload_len); diff --git a/src/WebSocketReceiver.h b/src/WebSocketReceiver.h index 01457d8..196190c 100644 --- a/src/WebSocketReceiver.h +++ b/src/WebSocketReceiver.h @@ -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; };