Three robustness/security fixes in the frame path - #120
marcocarnut wants to merge 3 commits into
Conversation
read_single_frame() writes a terminating NUL at msg[*msg_idx] whenever it finishes a FIN frame (the `if (fsd->is_fin && *frame_size > 0)` block). For a control frame, `msg` is the fixed-size `msg_ctrl` buffer and `*msg_idx` is `msg_idx_ctrl`. A control frame is allowed to carry a full 125-byte payload (the guard in next_complete_frame() only rejects frame_length > 125), after which the mask-copy loop has advanced *msg_idx to 125. The subsequent `msg[*msg_idx] = '\0';` therefore writes msg_ctrl[125] -- one byte past the 125-byte array. Reproduce with a masked 125-byte PING: `0x89 0xFD <mask x4> <125 payload bytes>`. On common struct layouts msg_ctrl[125] lands in alignment padding, so the stray NUL is usually harmless -- but it is a genuine out-of-bounds write / undefined behaviour and is fragile: reordering or packing the struct, or changing the buffer size, turns it into corruption of the following field. Fix by sizing the buffer 125 + 1 so there is always room for the terminator. No behavioural change for any valid frame. Signed-off-by: Marco Carnut <kikocarnut@gmail.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FCfAt4kFaCVqEc5Mjax5kz
read_single_frame() reads the mask/length byte into fsd.mask and derives frame_length from its low 7 bits, but never checks the high MASK bit. RFC 6455 section 5.1 requires every client-to-server frame to be masked and requires the server to fail the connection otherwise. Without the check an unmasked frame is silently mis-parsed: the next 4 payload bytes are consumed as a masking key and the remainder is unmasked against them, desynchronising the frame stream. Close with 1002 (protocol error) on an unmasked frame. Browsers and conforming clients always mask, so no correct client is affected; this also fixes the relevant Autobahn masking cases. Signed-off-by: Marco Carnut <kikocarnut@gmail.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FCfAt4kFaCVqEc5Mjax5kz
|
Hi @marcocarnut, thanks for the PR. Since many WebSocket use-cases are meant to be long running sessions of many minutes (or even hours), it's unnaceptable to close a connection in such short period. |
ws_accept() sets SO_SNDTIMEO from timeout_ms but never a receive timeout, so every recv() on a client socket blocks indefinitely. A client that completes the TCP connection and then sends nothing (or dribbles bytes) parks its handler thread forever inside do_handshake(); with the fixed MAX_CLIENTS pool a handful of such idle sockets consume every slot and deny service to all other clients at essentially zero cost. Set SO_RCVTIMEO on the accepted socket, but only for the handshake: ws_establishconnection() clears it the moment do_handshake() succeeds, so an established connection may stay idle on the read side indefinitely -- long-lived sessions that receive rarely (a remote touchpad, a notifier, etc.) are a first-class use case and must not be closed just for being quiet. Only the pre-handshake window, where no legitimate client has any reason to stall, is bounded. Gated on the existing timeout_ms, so deployments that leave it at 0 are unaffected. Abuse *after* a completed handshake is out of scope here and is best handled with application heartbeats (ws_ping()). Signed-off-by: Marco Carnut <kikocarnut@gmail.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FCfAt4kFaCVqEc5Mjax5kz
6658473 to
55a0cfa
Compare
You're entirely right, I overdid it -- my usecase streams continuously, but many (most?) don't. I was thinking of avoiding cheap resource exhaustion attacks like slowloris. But the timeout only ever needs to cover the handshake -- that's the window a zero-effort slowloris exploits (connect, send nothing, park a thread; repeat So I've revised the third commit to scope it: I tested it both ways: a client that connects and stays silent is closed at the handshake timeout, while an established session left idle for well over the timeout still sends and receives normally. Abuse after a completed handshake I've left out of scope here — as you say, that's the app's call (heartbeats / Branch is force-pushed with the revised commit. Thanks for the review! |
|
Hi @marcocarnut, Just a few things:
|
Three robustness/security fixes in the frame path
While hardening a wsServer-based service ahead of wider exposure, a security review turned up three issues in the frame-handling path. All three are small, self-contained, and independent — feel free to take, drop, or split any of them. Against current
master:1. Off-by-one out-of-bounds write on a 125-byte control frame (
00622ff)read_single_frame()writes a terminating NUL atmsg[*msg_idx]when it finishes a FIN frame. For a control framemsgis the fixedmsg_ctrlbuffer and a control payload may be a full 125 bytes (the guard only rejects> 125), after which*msg_idx == 125— somsg[*msg_idx] = '\0';writesmsg_ctrl[125], one past the 125-byte array.Reproduce with a masked 125-byte PING:
0x89 0xFD <mask×4> <125 bytes>. Usually masked by struct padding, but genuine UB and layout-fragile. Fix: size the buffer125 + 1; no behaviour change for any valid frame.2. Reject unmasked client frames — RFC 6455 §5.1 (
a71fe68)The MASK bit is read into
fsd.maskbut never checked. RFC 6455 §5.1 requires every client→server frame to be masked and the server to fail the connection otherwise. Without the check an unmasked frame is mis-parsed (the first payload bytes are consumed as the absent masking key). Fix: close with 1002 on an unmasked frame. Conforming clients always mask, so none are affected; also fixes the corresponding Autobahn cases.3. Bound blocking receives with
SO_RCVTIMEO(3b276db)ws_accept()setsSO_SNDTIMEOfromtimeout_msbut never a receive timeout, so everyrecv()on a client socket blocks indefinitely. A client that connects and then sends nothing (or one byte at a time) parks its handler thread forever; with theMAX_CLIENTSpool a handful of idle sockets deny service to everyone else (a trivial slowloris). Fix: setSO_RCVTIMEOto the same value, gated on the sametimeout_ms, so it is opt-in and deployments that leavetimeout_msat 0 are unaffected.Each commit is
Signed-off-by. Thanks for wsServer!