Skip to content

Three robustness/security fixes in the frame path - #120

Open
marcocarnut wants to merge 3 commits into
Theldus:masterfrom
marcocarnut:security-hardening
Open

marcocarnut wants to merge 3 commits into
Theldus:masterfrom
marcocarnut:security-hardening

Conversation

@marcocarnut

Copy link
Copy Markdown

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 at msg[*msg_idx] when it finishes a FIN frame. For a control frame msg is the fixed msg_ctrl buffer and a control payload may be a full 125 bytes (the guard only rejects > 125), after which *msg_idx == 125 — so msg[*msg_idx] = '\0'; writes msg_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 buffer 125 + 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.mask but 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() sets SO_SNDTIMEO from timeout_ms but never a receive timeout, so every recv() 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 the MAX_CLIENTS pool a handful of idle sockets deny service to everyone else (a trivial slowloris). Fix: set SO_RCVTIMEO to the same value, gated on the same timeout_ms, so it is opt-in and deployments that leave timeout_ms at 0 are unaffected.

Each commit is Signed-off-by. Thanks for wsServer!

marcocarnut and others added 2 commits September 16, 2026 11:18
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
@Theldus
Theldus self-requested a review September 16, 2026 23:37
@Theldus

Theldus commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Hi @marcocarnut, thanks for the PR.
The first two commits are fine and fix real issues, thanks for bringing them.
Regarding the third commit I need some clarifications: adding a SO_RCVTIMEO in the socket means that the client must always send new messages in at most 1 second. This breaks the vtouchpad for example, which is a long-running process.

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
@marcocarnut

Copy link
Copy Markdown
Author

Hi @marcocarnut, thanks for the PR. The first two commits are fine and fix real issues, thanks for bringing them. Regarding the third commit I need some clarifications: adding a SO_RCVTIMEO in the socket means that the client must always send new messages in at most 1 second. This breaks the vtouchpad for example, which is a long-running process.

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.

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 MAX_CLIENTS times). After the handshake there's no reason to keep it.

So I've revised the third commit to scope it: ws_accept() still sets SO_RCVTIMEO, but ws_establishconnection() clears it the instant do_handshake() succeeds. An established connection then blocks on recv() indefinitely, exactly as before -- vtouchpad is unaffected. Only the pre-handshake read is bounded, and still only when timeout_ms is set.

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 / ws_ping()), and it's a much higher bar for an attacker than the pre-handshake case anyway.

Branch is force-pushed with the revised commit. Thanks for the review!

@Theldus

Theldus commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Hi @marcocarnut,
Thanks for your review.

Just a few things:

  • SO_RCVTIME0 on Windows receives a DWORD of milliseconds, not a struct timeval, where '0' also means blocking. Could you review this? I think an #ifdef _WIN32 is enough.

  • Your second commit mentions that it is fixing Autobahn tests where it sends unmasked frames. However, Autobahn do not tests for unmasked frames. Could you reword this message?

  • I see that you declaring 'struct timeval' mid-block, could you move to the top of the function? to follow the remaining coding-style.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants