WebSocket
rusty-websocket is an RFC 6455 frame codec: it encodes and decodes WebSocket frames and messages, enforces the masking rules for client and server roles, reassembles fragmented messages under size caps, validates UTF-8 text and close codes, and computes the Sec-WebSocket-Key handshake. It is the byte layer, not a driven connection object.
The Node side of this is familiar: new WebSocket(url) in the browser, ws.on('message', ...) on the server, and underneath, a library (ws, or Rust's tungstenite) turning the raw bytes of RFC 6455 into messages. That layer masked the client frames, reassembled the fragments, and closed the socket with code 1007 when a peer sent text that was not valid UTF-8, all without asking. The frame codec is the part of a WebSocket stack that touches attacker bytes first, and it is the part that usually goes unaudited. rusty-websocket sits exactly there.
Alpha (Cruft 0.0.10).rusty-websocket(cratewebsocket) is an RFC 6455 frame codec that stands in fortungstenite. It has not been externally audited and has no interop run against a live partner. Do not rely on it in production or against adversarial input.
What the frame codec covers
tungstenite gives you a WebSocket<Stream> that owns a TCP/TLS connection and runs the read loop: something you .send() to, something that emits 'message', 'close', 'ping'.
rusty-websocket is a codec. It owns zero sockets, spawns no read loop, and holds no connection state machine. Its job is the pure function in the middle: bytes in, Frame/Message out, and back. The TCP/TLS transport binding is out of scope and belongs to a caller. The crate replaces the byte-mangling core that ws wraps, not the ws object itself.
What it exposes:
Opcode,Frame,Messagevalue types, andis_control()for the Close/Ping/Pong distinction.encode_frame/decode_frame: the low-level RFC vector codec (raw, role-agnostic).encode_close/decode_close,validate_close_code.generate_key,derive_accept,verify_accept: theSec-WebSocket-Key/Sec-WebSocket-AcceptSHA-1 handshake math (leaning onrusty-web-cryptofor SHA-1 and random bytes).
That surface alone is the part most WebSocket implementations get wrong, and the crate handles more than the surface.
Why the codec is role-aware
RFC 6455 §5.1 has a rule that browsers rely on invisibly: a client MUST mask every frame it sends; a server MUST close the connection if it receives an unmasked frame. Masking exists to stop a malicious script from steering attacker-chosen bytes through an intermediary cache. A codec that just reads "is the mask bit set?" and hands you the frame either way has silently discarded that protection.
That was the crate's original shape. It is now role-aware:
decode_server_framerejects an unmasked client frame withWsError::UnmaskedClientFrame.decode_client_framerejects a masked server frame withWsError::MaskedServerFrame.encode_client_framerefuses to emit an unmasked frame;encode_server_framerefuses to emit a masked one.
The raw encode_frame / decode_frame are still there for callers who want to parse RFC vectors without a role, but the safe path now has a role. Choose server or client and the mask rule is enforced, the way tungstenite enforces it by construction.
Fragmentation bombs and the size caps
WebSocket lets a message arrive as a stream of continuation fragments. Nothing in the frame header caps the total reassembled size, so a peer can dribble an unbounded message one fragment at a time. That is a fragmentation bomb.
Originally rusty-websocket had a single-frame cap of 2^63-1 (no practical bound) and no reassembly at all. Now:
FrameLimitscarriesmax_frame_payloadandmax_message_payload, defaulting to 16 MiB per frame and 64 MiB per message.decode_frame_with_limitsrejects an over-cap declared length withWsError::PayloadTooLongbefore copying the payload (the declared length is checked before it is used).MessageReassemblertracks the in-progress opcode, enforces the total-message cap (WsError::MessageTooLong), and rejects an orphan continuation or an interleaved data frame with explicit ordering errors.
The original problem had a bounded half: even before the fix, the codec did not itself OOM, because it only allocated from the already-materialized input slice. What it lacked was a policy to hand an upstream buffering reader. Now it has one, and the previously misleading PayloadTooLong message (which used to name 2^31 while enforcing 2^63) names the frame policy.
Validating text, close, and handshake bytes
Node code does JSON.parse(msg) on a text frame without wondering whether the bytes were valid UTF-8, because the layer below guaranteed it, or closed the socket with 1007 if it could not. Invalid UTF-8 in a text frame is a mandatory close per RFC 6455. A codec that returns raw bytes and lets you find out at JSON.parse time has moved a protocol violation into the application. That is now handled:
MessageReassemblervalidates a completed text message as UTF-8 and returnsWsError::InvalidTextUtf8on failure;validate_close_codeaccepts only 1000-1003 and 1007-1014.decode_closeis a strict parser: it rejects a one-byte payload, invalid UTF-8 reason text, and reserved/out-of-range codes, where it used to lean onfrom_utf8_lossyand silently substitute replacement characters.validate_server_handshake_requestvalidatesSec-WebSocket-Keyas base64-decoded 16 bytes, requiresSec-WebSocket-Version: 13, and exposes an origin allow-list hook (HandshakePolicy).
Limitations
The security behaviors above are default tests, but a focused unit test is not a conformance certificate. Be precise about what each buys.
- No Autobahn, no interop. The security behaviors (masking role, size/reassembly caps, invalid text/close/handshake) are covered by default tests, and a fuzz scaffold drives raw decode, bounded decode, role-aware decode, and reassembly. What is still absent is a run of the Autobahn Testsuite, the standard adversarial WebSocket conformance suite that
tungstenitepasses. The specific attacks named above are tested; the long tail of RFC edge cases Autobahn probes is not yet covered.
- It is a codec, so the session-layer bugs are yours. Ping/pong keepalive timing, close handshake sequencing (who sends the close first, the drain), and the read loop over a TLS socket live in the caller.
tungstenitegives you a drivenWebSocketobject that handles this;rusty-websocketgives you the frame functions and leaves the loop to you. A correct codec wired into a wrong loop is still a wrong WebSocket.
tungsteniteis the maturity baseline and it leads on exposure. It has years of production traffic, passes Autobahn, and has been read by many eyes.rusty-websocketis Cruft 0.0.10, days old at the surfaces that matter, tested against the specific bugs its authors already found. That is a real and narrow guarantee, and not the same one.
- The handshake HTTP layer is thin. The crate validates the Key/Version/Origin of an inbound upgrade request, but the full HTTP upgrade negotiation (parsing headers, subprotocol selection, extensions like permessage-deflate) is not this crate's job and is largely absent from the stack.