morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
ws_frame.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <array>
6#include <bit>
7#include <cstddef>
8#include <cstdint>
9#include <cstring>
10#include <optional>
11#include <random>
12#include <ranges>
13#include <span>
14#include <stdexcept>
15#include <string>
16#include <string_view>
17
18#include "../../core/wire.hpp"
19
20namespace morph::net::detail {
21
22/// @brief RFC 6455 §5.2 frame opcodes this reference implementation understands.
23enum class WsOpcode : std::uint8_t {
24 kContinuation = 0x0,
25 kText = 0x1,
26 kBinary = 0x2,
27 kClose = 0x8,
28 kPing = 0x9,
29 kPong = 0xA,
30};
31
32/// @brief One decoded (unfragmented) WebSocket frame.
33struct WsFrame {
34 /// @brief The frame's opcode.
35 WsOpcode opcode{WsOpcode::kText};
36 /// @brief The frame's (already-unmasked, if it was masked) payload bytes.
37 std::string payload;
38};
39
40namespace ws_frame_impl {
41// RFC 6455 §5.3 requires the mask key be unpredictable. A `std::mt19937`
42// seeded once per thread is fully reconstructible from 624 observed 32-bit
43// outputs, and a server that terminates many connections on one thread hands
44// an attacker exactly that many keys. `std::random_device` is used instead:
45// it has no reproducible internal state to recover, so a peer learns nothing
46// about the next key from any number of observed ones.
47//
48// It is held `thread_local` rather than constructed per frame: construction
49// is what acquires the entropy source (on libstdc++ without RDRAND/RDSEED,
50// an `open()` of `/dev/urandom`), so a fresh object per frame would add an
51// open/read/close round trip to *every* outbound message on the hot send
52// path. A `thread_local` costs nothing in unpredictability -- `operator()`
53// draws fresh entropy on each call regardless -- and one 32-bit draw already
54// carries all four mask bytes.
55inline std::array<std::uint8_t, 4> randomMaskKey() {
56 static thread_local std::random_device entropy;
57 std::random_device::result_type const bits = entropy();
58 return std::array<std::uint8_t, 4>{static_cast<std::uint8_t>(bits >> 24U), static_cast<std::uint8_t>(bits >> 16U),
59 static_cast<std::uint8_t>(bits >> 8U), static_cast<std::uint8_t>(bits)};
60}
61
62/// @brief RFC 6455 §7.4.1 status codes a Close frame is allowed to carry.
63/// 1004, 1005, 1006 and 1015 are explicitly reserved and MUST NOT appear on
64/// the wire. 1000-1003 and 1007-1014 are assigned in the IANA WebSocket Close
65/// Code Number Registry (1012 Service Restart, 1013 Try Again Later and 1014
66/// Bad Gateway were registered after RFC 6455 shipped, and a proxy in front of
67/// a real server does send them -- rejecting them would drop a legitimate
68/// peer). 1015-2999 is unassigned registry space no endpoint may invent a code
69/// in; 3000-4999 is reserved for libraries/frameworks and private use.
70inline bool isValidCloseCode(std::uint16_t code) {
71 if (code >= 1000 && code <= 1003) {
72 return true;
73 }
74 if (code >= 1007 && code <= 1014) {
75 return true;
76 }
77 return code >= 3000 && code <= 4999;
78}
79
80/// @brief Incremental UTF-8 validator for RFC 6455 §5.6's requirement that a
81/// text message's payload be valid UTF-8 -- checked here because a multi-byte
82/// sequence can straddle a fragment boundary, so validating each fragment in
83/// isolation would reject legal input.
84class Utf8Validator {
85public:
86 /// @return `false` as soon as a byte cannot extend valid UTF-8; once it
87 /// returns `false` the validator must not be fed further.
88 bool feed(std::string_view data) {
89 while (!data.empty()) {
90 if (_remaining == 0) {
91 data = skipAscii(data);
92 if (data.empty()) {
93 break;
94 }
95 }
96 auto const currentByte = static_cast<unsigned char>(data.front());
97 data.remove_prefix(1);
98 if (!(_remaining == 0 ? startSequence(currentByte) : continueSequence(currentByte))) {
99 return false;
100 }
101 }
102 return true;
103 }
104
105 /// @return `true` if no multi-byte sequence was left dangling -- call at
106 /// the end of a (possibly reassembled) text message.
107 [[nodiscard]] bool complete() const { return _remaining == 0; }
108
109private:
110 /// @brief The part of @p data from its first byte that is not ASCII;
111 /// empty when every byte is ASCII.
112 ///
113 /// Eight bytes at a time: an envelope is JSON, almost all ASCII, and a
114 /// byte-at-a-time walk over it is most of what validation costs.
115 static std::string_view skipAscii(std::string_view data) {
116 constexpr std::uint64_t kHighBits = 0x8080808080808080ULL;
117 std::uint64_t word = 0;
118 while (data.size() >= sizeof word) {
119 std::memcpy(&word, data.data(), sizeof word);
120 if ((word & kHighBits) != 0) {
121 break;
122 }
123 data.remove_prefix(sizeof word);
124 }
125 while (!data.empty() && static_cast<unsigned char>(data.front()) < 0x80U) {
126 data.remove_prefix(1);
127 }
128 return data;
129 }
130
131 /// @brief Consumes a byte that starts a new (possibly single-byte) sequence.
132 bool startSequence(unsigned char currentByte) {
133 if (currentByte < 0x80) {
134 return true;
135 }
136 if ((currentByte & 0xE0U) == 0xC0U) {
137 if (currentByte < 0xC2U) {
138 return false; // overlong 2-byte lead (0xC0/0xC1)
139 }
140 _remaining = 1;
141 _codepoint = currentByte & 0x1FU;
142 _minCodepoint = 0x80;
143 return true;
144 }
145 if ((currentByte & 0xF0U) == 0xE0U) {
146 _remaining = 2;
147 _codepoint = currentByte & 0x0FU;
148 _minCodepoint = 0x800;
149 return true;
150 }
151 if ((currentByte & 0xF8U) == 0xF0U) {
152 if (currentByte > 0xF4U) {
153 return false; // beyond the U+10FFFF range
154 }
155 _remaining = 3;
156 _codepoint = currentByte & 0x07U;
157 _minCodepoint = 0x10000;
158 return true;
159 }
160 return false; // stray continuation byte or 0xF8-0xFF
161 }
162
163 /// @brief Consumes a byte expected to continue a sequence `startSequence` began.
164 bool continueSequence(unsigned char currentByte) {
165 if ((currentByte & 0xC0U) != 0x80U) {
166 return false; // expected a continuation byte
167 }
168 _codepoint = (_codepoint << 6) | (currentByte & 0x3FU);
169 if (--_remaining != 0) {
170 return true;
171 }
172 return _codepoint >= _minCodepoint && _codepoint <= 0x10FFFFU &&
173 (_codepoint < 0xD800U || _codepoint > 0xDFFFU); // overlong, out of range, or a surrogate half
174 }
175
176 unsigned _remaining{0};
177 std::uint32_t _codepoint{0};
178 std::uint32_t _minCodepoint{0};
179};
180} // namespace ws_frame_impl
181
182/// @brief Encodes one complete (unfragmented, `FIN=1`) WebSocket frame.
183/// @param opcode Frame opcode.
184/// @param payload Payload bytes.
185/// @param mask `true` to mask the frame (required for client-to-server
186/// frames per RFC 6455 §5.1); `false` for server-to-client frames.
187/// @return The wire bytes of the frame, ready to send on the socket.
188inline std::string encodeWsFrame(WsOpcode opcode, std::string_view payload, bool mask) {
189 std::string out;
190 out.push_back(static_cast<char>(0x80u | static_cast<std::uint8_t>(opcode))); // FIN=1, opcode
191 std::uint64_t const len = payload.size();
192 std::uint8_t const maskBit = mask ? 0x80u : 0x00u;
193 if (len <= 125) {
194 out.push_back(static_cast<char>(maskBit | static_cast<std::uint8_t>(len)));
195 } else if (len <= 0xFFFFu) {
196 out.push_back(static_cast<char>(maskBit | 126u));
197 out.push_back(static_cast<char>((len >> 8) & 0xFFu));
198 out.push_back(static_cast<char>(len & 0xFFu));
199 } else {
200 out.push_back(static_cast<char>(maskBit | 127u));
201 for (int shift = 56; shift >= 0; shift -= 8) {
202 out.push_back(static_cast<char>((len >> shift) & 0xFFu));
203 }
204 }
205 if (mask) {
206 auto key = ws_frame_impl::randomMaskKey();
207 for (auto b : key) {
208 out.push_back(static_cast<char>(b));
209 }
210 for (std::size_t i = 0; i < payload.size(); ++i) {
211 out.push_back(static_cast<char>(static_cast<std::uint8_t>(payload[i]) ^ key[i % key.size()]));
212 }
213 } else {
214 out.append(payload);
215 }
216 return out;
217}
218
219/// @brief Streaming WebSocket message decoder over a byte buffer fed incrementally.
220///
221/// `encodeWsFrame` never fragments what *this* implementation sends — the
222/// 64-bit extended-length field covers up to `wire::kMaxEnvelopeBytes`, so one
223/// `wire::Envelope` always goes out as a single frame. Incoming traffic is a
224/// different matter: a peer fragments according to *its* outgoing frame size,
225/// and Qt's `QWebSocket` defaults that to 512 KiB. `tryExtractFrame` therefore
226/// reassembles continuation frames and hands back only completed messages.
227class WsFrameReader {
228public:
229 /// @param expectMasked RFC 6455 §5.1: a server MUST reject an unmasked
230 /// frame from a client, and a client MUST reject a masked frame
231 /// from a server. Pass `true` for a reader consuming client-to-
232 /// server traffic (i.e. inside a server), `false` for one
233 /// consuming server-to-client traffic (i.e. inside a client).
234 explicit WsFrameReader(bool expectMasked) : _expectMasked(expectMasked) {}
235
236 /// @brief Appends newly received bytes to the internal buffer.
237 /// @param data Bytes read from the socket.
238 void feed(std::string_view data) { _buf.append(data); }
239
240 /// @brief Attempts to extract one complete *message* from the buffered bytes.
241 ///
242 /// Reassembles fragmented messages (RFC 6455 §5.4): a data message may
243 /// arrive as an unfinished text/binary frame followed by continuation
244 /// frames, with the last carrying FIN. Fragments are accumulated and only
245 /// the completed message is returned, carrying the opcode of its first
246 /// frame. This is not an exotic case — a peer fragments whenever a message
247 /// exceeds its outgoing frame size, and Qt's `QWebSocket` defaults that to
248 /// 512 KiB, so rejecting fragments broke interop with the transport this
249 /// project ships for every payload past that size.
250 ///
251 /// Control frames (close/ping/pong) may be interleaved between the
252 /// fragments of a data message and are returned as they arrive, without
253 /// disturbing the reassembly in progress. Per the RFC they may not
254 /// themselves be fragmented.
255 ///
256 /// @return The completed message if enough bytes are buffered, `std::nullopt`
257 /// otherwise.
258 /// @throws std::runtime_error on a protocol violation (a continuation with
259 /// no message in progress, a new data frame interrupting one, a
260 /// fragmented control frame) or a payload — single frame or
261 /// reassembled total — larger than `wire::kMaxEnvelopeBytes`.
262 std::optional<WsFrame> tryExtractFrame() {
263 for (;;) {
264 auto frame = tryExtractRawFrame();
265 if (!frame) {
266 return std::nullopt;
267 }
268 if (isControlOpcode(frame->opcode)) {
269 return handleControlFrame(*frame);
270 }
271 if (frame->opcode == WsOpcode::kContinuation) {
272 if (auto completed = handleContinuationFrame(*frame)) {
273 return completed;
274 }
275 continue; // more fragments to come; keep draining the buffer
276 }
277 if (auto completed = handleDataFrame(*frame)) {
278 return completed;
279 }
280 // else: `frame` started a fragmented message, now buffered internally.
281 }
282 }
283
284private:
285 /// @brief One frame exactly as it appeared on the wire, before any
286 /// fragmentation handling.
287 struct RawFrame {
288 bool fin;
289 WsOpcode opcode;
290 std::string payload;
291 };
292
293 // No `!frame.fin` check here: tryExtractRawFrame()'s validateControlFraming()
294 // already rejects a fragmented control frame before a RawFrame is ever
295 // returned, so every control frame reaching this point already has FIN=1.
296 static WsFrame handleControlFrame(RawFrame& frame) {
297 return WsFrame{.opcode = frame.opcode, .payload = std::move(frame.payload)};
298 }
299
300 /// @return The completed message once its last continuation (FIN=1)
301 /// arrives; `std::nullopt` while more fragments are expected.
302 std::optional<WsFrame> handleContinuationFrame(RawFrame& frame) {
303 if (!_assembling) {
304 throw std::runtime_error("WsFrameReader: continuation frame with no message in progress");
305 }
306 feedAssemblyTextOrThrow(frame.payload);
307 appendFragment(frame.payload);
308 if (!frame.fin) {
309 return std::nullopt;
310 }
311 if (assemblyIsText() && !_textUtf8.complete()) {
312 resetAssembly();
313 throw std::runtime_error("WsFrameReader: text message ends mid-UTF-8-sequence");
314 }
315 WsFrame completed{.opcode = _assemblyOpcode, .payload = std::move(_assembly)};
316 resetAssembly();
317 return completed;
318 }
319
320 /// @brief Handles a data (text/binary) frame, which starts a new message.
321 /// @return The message immediately if `frame` was unfragmented (FIN=1);
322 /// `std::nullopt` if it started a fragmented message instead.
323 std::optional<WsFrame> handleDataFrame(RawFrame& frame) {
324 if (_assembling) {
325 throw std::runtime_error("WsFrameReader: new data frame while a fragmented message is in progress");
326 }
327 if (frame.opcode == WsOpcode::kText && frame.fin) {
328 ws_frame_impl::Utf8Validator validator;
329 if (!validator.feed(frame.payload) || !validator.complete()) {
330 throw std::runtime_error("WsFrameReader: text message contains invalid UTF-8");
331 }
332 }
333 if (frame.fin) {
334 return WsFrame{.opcode = frame.opcode,
335 .payload = std::move(frame.payload)}; // the common, unfragmented case
336 }
337 _assembling = true;
338 _assemblyOpcode = frame.opcode;
339 _textUtf8 = ws_frame_impl::Utf8Validator{};
340 feedAssemblyTextOrThrow(frame.payload);
341 _assembly.clear();
342 appendFragment(frame.payload);
343 return std::nullopt;
344 }
345
346 /// @brief Whether the message currently being reassembled is a text one,
347 /// and so subject to RFC 6455 §5.6's UTF-8 requirement. Derived
348 /// from `_assemblyOpcode` rather than tracked alongside it, so the
349 /// two cannot drift. Only meaningful while `_assembling`.
350 [[nodiscard]] bool assemblyIsText() const { return _assemblyOpcode == WsOpcode::kText; }
351
352 /// @brief Feeds one fragment of the in-progress message to the incremental
353 /// UTF-8 validator when that message is a text one, discarding the
354 /// partial message and throwing if the bytes cannot extend valid
355 /// UTF-8. A no-op for a binary message, whose payload is opaque.
356 void feedAssemblyTextOrThrow(const std::string& payload) {
357 if (assemblyIsText() && !_textUtf8.feed(payload)) {
358 resetAssembly();
359 throw std::runtime_error("WsFrameReader: text message contains invalid UTF-8");
360 }
361 }
362
363 void resetAssembly() {
364 _assembling = false;
365 _textUtf8 = ws_frame_impl::Utf8Validator{};
366 _assembly.clear();
367 _assembly.shrink_to_fit();
368 }
369
370 void appendFragment(const std::string& payload) {
371 // Bound the reassembled total, not just each frame: without this, a
372 // peer could stream unlimited 1-byte continuations and grow this buffer
373 // without ever tripping the per-frame cap.
374 if (_assembly.size() + payload.size() > ::morph::wire::kMaxEnvelopeBytes) {
375 resetAssembly();
376 throw std::runtime_error("WsFrameReader: reassembled message exceeds kMaxEnvelopeBytes");
377 }
378 _assembly += payload;
379 }
380
381 /// @brief Validates RSV/opcode and returns the opcode, or throws.
382 static WsOpcode validateFirstByte(std::uint8_t byte0) {
383 if ((byte0 & 0x70U) != 0) {
384 throw std::runtime_error("WsFrameReader: RSV bit set with no extension negotiated");
385 }
386 auto const opcode = static_cast<WsOpcode>(static_cast<std::uint8_t>(byte0 & 0x0FU));
387 switch (opcode) {
388 case WsOpcode::kContinuation:
389 case WsOpcode::kText:
390 case WsOpcode::kBinary:
391 case WsOpcode::kClose:
392 case WsOpcode::kPing:
393 case WsOpcode::kPong:
394 return opcode;
395 default:
396 throw std::runtime_error("WsFrameReader: reserved opcode");
397 }
398 }
399
400 void validateMaskDirection(bool masked) const {
401 if (masked != _expectMasked) {
402 throw std::runtime_error(_expectMasked ? "WsFrameReader: unmasked frame from a client"
403 : "WsFrameReader: masked frame from a server");
404 }
405 }
406
407 static bool isControlOpcode(WsOpcode opcode) { return (static_cast<std::uint8_t>(opcode) & 0x08U) != 0; }
408
409 static void validateControlFraming(bool isControl, bool fin, std::uint8_t lenMarker) {
410 if (!isControl) {
411 return;
412 }
413 if (!fin) {
414 throw std::runtime_error("WsFrameReader: control frames must not be fragmented");
415 }
416 if (lenMarker > 125) {
417 throw std::runtime_error("WsFrameReader: control frame payload exceeds 125 bytes");
418 }
419 }
420
421 /// @brief The result of decoding a (possibly extended) length field.
422 struct LengthDecode {
423 std::uint64_t payloadLen;
424 std::size_t headerLen;
425 };
426
427 /// @return `std::nullopt` if the extended-length bytes are not fully
428 /// buffered yet; throws on a non-minimal encoding.
429 [[nodiscard]] std::optional<LengthDecode> decodeLength(std::uint8_t lenMarker) const {
430 std::uint64_t payloadLen = lenMarker;
431 std::size_t headerLen = 2;
432 if (payloadLen == 126) {
433 if (_buf.size() < 4) {
434 return std::nullopt;
435 }
436 payloadLen = (static_cast<std::uint64_t>(static_cast<std::uint8_t>(_buf[2])) << 8) |
437 static_cast<std::uint64_t>(static_cast<std::uint8_t>(_buf[3]));
438 if (payloadLen <= 125) {
439 throw std::runtime_error("WsFrameReader: non-minimal 16-bit length encoding");
440 }
441 headerLen = 4;
442 } else if (payloadLen == 127) {
443 if (_buf.size() < 10) {
444 return std::nullopt;
445 }
446 payloadLen = 0;
447 for (std::size_t i = 0; i < 8; ++i) {
448 payloadLen = (payloadLen << 8) | static_cast<std::uint8_t>(_buf[2 + i]);
449 }
450 if (payloadLen <= 0xFFFFU) {
451 throw std::runtime_error("WsFrameReader: non-minimal 64-bit length encoding");
452 }
453 headerLen = 10;
454 }
455 return LengthDecode{.payloadLen = payloadLen, .headerLen = headerLen};
456 }
457
458 /// @brief Copies out and (if masked) unmasks the payload bytes.
459 [[nodiscard]] std::string extractPayload(std::size_t headerLen, std::size_t maskLen, std::size_t payloadLen,
460 bool masked) const {
461 std::string payload = _buf.substr(headerLen + maskLen, payloadLen);
462 if (masked) {
463 std::array<std::uint8_t, 4> key{};
464 for (std::size_t i = 0; i < 4; ++i) {
465 key[i] = static_cast<std::uint8_t>(_buf[headerLen + i]);
466 }
467 // Eight bytes at a time, with the key repeated twice: every word
468 // starts at a multiple of eight, so its bytes line up with the
469 // key's. The tail, shorter than a word, is XORed as the front of one.
470 std::array<std::uint8_t, 8> doubled{};
471 std::ranges::copy(key, std::ranges::copy(key, doubled.begin()).out);
472 auto const wideKey = std::bit_cast<std::uint64_t>(doubled);
473 std::span<char> rest{payload};
474 std::uint64_t word = 0;
475 while (rest.size() >= sizeof word) {
476 std::memcpy(&word, rest.data(), sizeof word);
477 word ^= wideKey;
478 std::memcpy(rest.data(), &word, sizeof word);
479 rest = rest.subspan(sizeof word);
480 }
481 std::memcpy(&word, rest.data(), rest.size());
482 word ^= wideKey;
483 std::memcpy(rest.data(), &word, rest.size());
484 }
485 return payload;
486 }
487
488 static void validateClosePayload(const std::string& payload) {
489 if (payload.empty()) {
490 return; // a Close with no body is legal and carries no status code
491 }
492 bool const validClosePayload =
493 payload.size() >= 2 &&
494 ws_frame_impl::isValidCloseCode(static_cast<std::uint16_t>(
495 (static_cast<std::uint8_t>(payload.at(0)) << 8) | static_cast<std::uint8_t>(payload.at(1))));
496 if (!validClosePayload) {
497 throw std::runtime_error("WsFrameReader: close frame carries an invalid status code");
498 }
499 // RFC 6455 §5.5.1: whatever follows the two status-code bytes is a
500 // human-readable reason that MUST be valid UTF-8, exactly as a Text
501 // message's payload must be. A Close body is never fragmented (control
502 // frames always carry FIN=1), so one self-contained pass suffices.
503 ws_frame_impl::Utf8Validator reasonUtf8;
504 if (!reasonUtf8.feed(std::string_view{payload}.substr(2)) || !reasonUtf8.complete()) {
505 throw std::runtime_error("WsFrameReader: close frame reason is not valid UTF-8");
506 }
507 }
508
509 std::optional<RawFrame> tryExtractRawFrame() {
510 if (_buf.size() < 2) {
511 return std::nullopt;
512 }
513 auto const byte0 = static_cast<std::uint8_t>(_buf.at(0));
514 auto const byte1 = static_cast<std::uint8_t>(_buf.at(1));
515 auto const opcode = validateFirstByte(byte0);
516 bool const fin = (byte0 & 0x80U) != 0;
517 bool const masked = (byte1 & 0x80U) != 0;
518 validateMaskDirection(masked);
519 std::uint8_t const lenMarker = byte1 & 0x7FU;
520 validateControlFraming(isControlOpcode(opcode), fin, lenMarker);
521
522 auto const decoded = decodeLength(lenMarker);
523 if (!decoded) {
524 return std::nullopt;
525 }
526 if (decoded->payloadLen > ::morph::wire::kMaxEnvelopeBytes) {
527 throw std::runtime_error("WsFrameReader: frame payload exceeds kMaxEnvelopeBytes");
528 }
529 std::size_t const maskLen = masked ? 4 : 0;
530 std::size_t const totalLen = decoded->headerLen + maskLen + static_cast<std::size_t>(decoded->payloadLen);
531 if (_buf.size() < totalLen) {
532 return std::nullopt;
533 }
534 std::string payload =
535 extractPayload(decoded->headerLen, maskLen, static_cast<std::size_t>(decoded->payloadLen), masked);
536 if (opcode == WsOpcode::kClose) {
537 validateClosePayload(payload);
538 }
539 _buf.erase(0, totalLen);
540 return RawFrame{.fin = fin, .opcode = opcode, .payload = std::move(payload)};
541 }
542
543 bool _expectMasked;
544 std::string _buf;
545 // Reassembly state for a fragmented data message: `_assembly` accumulates
546 // the payload and `_assemblyOpcode` remembers the opcode of the first
547 // frame, since continuations carry opcode 0. `_textUtf8` tracks incremental
548 // UTF-8 validity across fragments, since a multi-byte sequence can straddle
549 // a fragment boundary.
550 bool _assembling{false};
551 WsOpcode _assemblyOpcode{WsOpcode::kText};
552 ws_frame_impl::Utf8Validator _textUtf8;
553 std::string _assembly;
554};
555
556} // namespace morph::net::detail