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 (crate websocket) is an RFC 6455 frame codec that stands in for tungstenite. 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, Message value types, and is_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: the Sec-WebSocket-Key / Sec-WebSocket-Accept SHA-1 handshake math (leaning on rusty-web-crypto for 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_frame rejects an unmasked client frame with WsError::UnmaskedClientFrame.
  • decode_client_frame rejects a masked server frame with WsError::MaskedServerFrame.
  • encode_client_frame refuses to emit an unmasked frame; encode_server_frame refuses 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:

  • FrameLimits carries max_frame_payload and max_message_payload, defaulting to 16 MiB per frame and 64 MiB per message.
  • decode_frame_with_limits rejects an over-cap declared length with WsError::PayloadTooLong before copying the payload (the declared length is checked before it is used).
  • MessageReassembler tracks 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:

  • MessageReassembler validates a completed text message as UTF-8 and returns WsError::InvalidTextUtf8 on failure; validate_close_code accepts only 1000-1003 and 1007-1014.
  • decode_close is 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 on from_utf8_lossy and silently substitute replacement characters.
  • validate_server_handshake_request validates Sec-WebSocket-Key as base64-decoded 16 bytes, requires Sec-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 tungstenite passes. 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. tungstenite gives you a driven WebSocket object that handles this; rusty-websocket gives you the frame functions and leaves the loop to you. A correct codec wired into a wrong loop is still a wrong WebSocket.
  • tungstenite is the maturity baseline and it leads on exposure. It has years of production traffic, passes Autobahn, and has been read by many eyes. rusty-websocket is 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.