Skip to content

Bound incoming WebSocket frames and validate close payloads - #248

Merged
swhitty merged 2 commits into
swhitty:mainfrom
PADL:websocket-bounds
Oct 12, 2026
Merged

swhitty merged 2 commits into
swhitty:mainfrom
PADL:websocket-bounds

Conversation

@lhoward

@lhoward lhoward commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

WSFrameEncoder.decodeFrame reads whatever payload length the frame header declares, up to Int.max: decodeLengthMask accepts a 2^40 byte length and decodePayload then calls nextBuffer(count:) for it. A client can declare such a frame and stream bytes until the server runs out of memory, and any message size check a handler makes comes after the payload has been assembled. We hit this with a server on a small ARM board where a single misbehaving client takes the process down.

Change

  • A maximum payload size is checked right after the length and mask are decoded, before any payload is read. WSFrameEncoder.decodeFrame(from:maxPayloadSize:), decodeClientFrame(from:maxPayloadSize:) and AsyncThrowingStream.decodingClientFrames(from:maxPayloadSize:) take it; the existing calls keep their behaviour through a default of .max.
  • The limit is configured with HTTPServer.Configuration.webSocketMaxFrameSize, 16 MiB by default, and HTTPConnection passes it to the client frame decoder.
  • WSFrameEncoder.Error gains closeCode (default .protocolError), so an oversized frame is answered with 1009 (Message Too Big) rather than a generic 1002. AsyncStream.protocolFrames sends that code; every other error still closes with 1002 as before.
  • WSFrameValidator applies the remaining close frame rules from RFC 6455 §5.5.1 and §7.4: a 1 byte payload, a status code outside 1000–1003, 1007–1014 and 3000–4999 (1005, 1006 and 1015 are reserved for local use; 1004 and 1016–2999 for future revisions), or a reason that is not UTF-8 each close with 1002. It already enforced the 125 byte limit and, since Reject control frames with payload >125 bytes per RFC 6455 §5.5 #214/cc5aea8, unmasked client frames are rejected at the decoder.

Testing

New tests: the decoder rejects a frame declaring 256 bytes against a 255 byte limit before reading any payload, and accepts one exactly at the limit; the client frame stream rejects an oversized frame; protocolFrames closes with the error's code; the validator's close frame rules; an HTTPConnection with a 1 KiB limit answers a 1025 byte frame with .close(code: .messageTooBig); and an HTTPServer configured with webSocketMaxFrameSize: 16 echoes a 16 byte message and closes with 1009 on a 17 byte one.

swift test on Linux (Swift 6.4): 310 tests, the same 3 issues as on main without this change (HTTPDecoderTests.standardizedPath and naughtyPath_IsParsed). Not run on Darwin locally.

A follow-up makes the frames↔messages path in MessageFrameWSHandler pull-based, so a client that stops reading cannot grow the output buffer either.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T8cahEd4GfsHQ89sNrmP4p

lhoward and others added 2 commits October 12, 2026 13:56
WSFrameEncoder.decodeFrame read whatever payload length the frame
header declared, up to Int.max, so a client could declare a 2^40 byte
frame and feed bytes until the server ran out of memory. Any size check
a handler makes on the assembled message comes after that.

Check the declared length against a maximum right after the length and
mask are decoded, before any payload is read. The limit is
HTTPServer.Configuration.webSocketMaxFrameSize, 16 MiB by default, and
HTTPConnection passes it to the client frame decoder. An oversized frame
fails the stream with a WSFrameEncoder.Error carrying close code 1009;
WSFrameEncoder.Error now names the close code a handler should send, so
MessageFrameWSHandler answers with 1009 (Message Too Big) instead of a
generic 1002.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T8cahEd4GfsHQ89sNrmP4p
WSFrameValidator checked only that a close frame's payload fits in 125
bytes. RFC 6455 §5.5.1 also requires a non-empty payload to begin with a
2 byte status code, §7.4 defines which codes an endpoint may send, and
the reason that follows must be UTF-8.

Reject a 1 byte payload, a status code outside 1000-1003, 1007-1014 and
3000-4999 (1005, 1006 and 1015 are reserved for local use; 1004 and
1016-2999 for future revisions), and a reason that is not valid UTF-8.
Each closes the connection with 1002 like the validator's other errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T8cahEd4GfsHQ89sNrmP4p
@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.79%. Comparing base (4d2d822) to head (b7cd7b5).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #248      +/-   ##
==========================================
+ Coverage   93.75%   93.79%   +0.04%     
==========================================
  Files          72       72              
  Lines        3793     3820      +27     
==========================================
+ Hits         3556     3583      +27     
  Misses        237      237              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@swhitty swhitty left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! I have empathy for that little ARM board that was taken down due to this issue! thanks

@swhitty
swhitty merged commit e01cf86 into swhitty:main Oct 12, 2026
14 checks passed
@lhoward

lhoward commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Claude may have exaggerated, lol. The test server that crashed was running on a Ryzen AI Max.

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