From 6a8029f812c7513eb9943a6fc727adf9be44bc0a Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Tue, 18 Aug 2026 07:08:10 +0200 Subject: [PATCH 1/2] Validate WebSocket close frames before answering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 6455 §5.5.1 requires an endpoint to echo back the status code it received in its own close frame, and §7.4 fixes which codes may appear on the wire at all. The handler did neither: a close frame's payload was never looked at and the reply was always a bare 0x88 0x00, so a 1-byte payload, a status code an endpoint must never receive, or a non-UTF-8 reason was accepted silently. close-response-code decodes the payload into the code to answer with: Nothing for an empty payload (bare close, as before), 1002 for a 1-byte payload or a code outside 1000-1003 / 1007-1011 / 3000-4999, 1007 for a reason that is not valid UTF-8, and otherwise the client's own code. 1012-1014 were registered with IANA after RFC 6455 and are rejected here, matching the strict §7.4.1 reading; all three are server-to-client codes a server has no business receiving. WSEvent.Close stays nullary and the handler is still dispatched in every case — this is wire behaviour only. --- CHANGELOG.md | 9 ++++ test/websocket.carp | 111 ++++++++++++++++++++++++++++++++++++++++++++ web.carp | 26 ++++++++++- 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffed09d..55450b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ proxies keep the stream open. The `SSE` module encodes the wire format on its own for handlers that need an `id`, a `retry` time, or a comment. +### Fixed +- **A WebSocket close frame is checked before it is answered.** A close + payload holding a single byte, a status code an endpoint must never receive + (0-999, 1004, 1005, 1006, 1012-2999, and anything above 4999), or a reason + that is not valid UTF-8 was accepted silently; such a frame now fails the + connection with 1002 or 1007. A well-formed close is answered with the + client's own status code echoed back, as RFC 6455 §5.5.1 asks for, and a + close carrying no payload is still answered with an empty close. + ## [0.9.3] ### Changed diff --git a/test/websocket.carp b/test/websocket.carp index 7096f36..68024d9 100644 --- a/test/websocket.carp +++ b/test/websocket.carp @@ -72,6 +72,15 @@ (Int.mod i 4))))))) frame)) +; Build a close frame payload: a big-endian u16 status code plus `reason` bytes. +(defn make-close-payload [code reason] + (let-do [payload (the (Array Byte) + [(Byte.from-int (bit-and (bit-shift-right code 8) 255)) + (Byte.from-int (bit-and code 255))])] + (for [i 0 (Array.length reason)] + (Array.push-back! &payload @(Array.unsafe-nth reason i))) + payload)) + ; Build a masked text frame with an explicit 8-byte (64-bit) declared length. ; No payload bytes follow — these exercise header length handling only. (defn make-64bit-len-frame [b2 b3 b4 b5 b6 b7 b8 b9] @@ -358,6 +367,108 @@ (WebSocket.control-frame-protocol-error? 15 true 0) "reserved control opcode 0xF is a protocol error (§5.2)") + ; RFC 6455 §5.5.1/§7.4 close payload validation. + (assert-equal test + &(the (Maybe Int) (Maybe.Nothing)) + &(WebSocket.close-response-code &(the (Array Byte) [])) + "an empty close payload is answered with a bare close") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &[3b]) + "a 1-byte close payload is a protocol error (§5.5.1)") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 0 &[])) + "close code 0 is rejected") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 999 &[])) + "close code 999 is rejected") + + (assert-equal test + &(Maybe.Just 1000) + &(WebSocket.close-response-code &(make-close-payload 1000 &[])) + "close code 1000 is echoed back") + + (assert-equal test + &(Maybe.Just 1003) + &(WebSocket.close-response-code &(make-close-payload 1003 &[])) + "close code 1003 is echoed back") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 1004 &[])) + "close code 1004 is rejected") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 1005 &[])) + "close code 1005 must not appear on the wire") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 1006 &[])) + "close code 1006 must not appear on the wire") + + (assert-equal test + &(Maybe.Just 1007) + &(WebSocket.close-response-code &(make-close-payload 1007 &[])) + "close code 1007 is echoed back") + + (assert-equal test + &(Maybe.Just 1011) + &(WebSocket.close-response-code &(make-close-payload 1011 &[])) + "close code 1011 is echoed back") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 1015 &[])) + "close code 1015 must not appear on the wire") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 1016 &[])) + "close code 1016 is rejected") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 2999 &[])) + "close code 2999 is rejected") + + (assert-equal test + &(Maybe.Just 3000) + &(WebSocket.close-response-code &(make-close-payload 3000 &[])) + "close code 3000 is echoed back") + + (assert-equal test + &(Maybe.Just 4999) + &(WebSocket.close-response-code &(make-close-payload 4999 &[])) + "close code 4999 is echoed back") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &(make-close-payload 5000 &[])) + "close code 5000 is rejected") + + (assert-equal test + &(Maybe.Just 1002) + &(WebSocket.close-response-code &[255b 255b]) + "close code 65535 is rejected") + + (assert-equal test + &(Maybe.Just 1000) + &(WebSocket.close-response-code + &(make-close-payload 1000 &(String.to-bytes "bye"))) + "a close reason that is valid UTF-8 keeps the code") + + (assert-equal test + &(Maybe.Just 1007) + &(WebSocket.close-response-code &(make-close-payload 1000 &[255b 254b])) + "a close reason that is not valid UTF-8 is answered with 1007 (§8.1)") + (assert-equal test "/" &(let [req (Result.unsafe-from-success diff --git a/web.carp b/web.carp index 5ccbf22..dad34b7 100644 --- a/web.carp +++ b/web.carp @@ -900,6 +900,26 @@ or `Nothing` if no subprotocol was negotiated.") (defn control-frame-protocol-error? [opcode fin plen] (or (not fin) (> plen 125) (and (/= opcode 8) (/= opcode 9) (/= opcode 10)))) + (hidden close-response-code) + ; RFC 6455 §5.5.1/§7.4: the status code to echo for a received close frame + ; (1002 malformed, 1007 non-UTF-8 reason), or Nothing when it had no payload. + (defn close-response-code [payload] + (let [len (Array.length payload)] + (cond + (= len 0) (Maybe.Nothing) + (= len 1) (Maybe.Just 1002) + (let [code (+ + (* (Byte.to-int @(Array.unsafe-nth payload 0)) 256) + (Byte.to-int @(Array.unsafe-nth payload 1)))] + (cond + (or (< code 1000) + (and (>= code 1004) (<= code 1006)) + (and (>= code 1012) (<= code 2999)) + (> code 4999)) + (Maybe.Just 1002) + (not (UTF8.valid? &(Array.suffix payload 2))) (Maybe.Just 1007) + (Maybe.Just code)))))) + (doc send "queues a text message for sending on the WebSocket connection.") (defn send [ws msg] (Array.push-back! (WebSocket.outbox ws) (encode-text msg))) @@ -2578,7 +2598,11 @@ fallback.") ¶ms &ws) (Array.push-back! (WebSocket.outbox &ws) - (WebSocket.encode-close)) + (match (WebSocket.close-response-code (WSFrame.payload &frame)) + (Maybe.Nothing) + (WebSocket.encode-close) + (Maybe.Just code) + (WebSocket.encode-close-with-code code))) (set! should-close true) (break)) ()) From a6b7acc259542dfd80936320621d93ce4aea4571 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Tue, 18 Aug 2026 12:57:19 +0200 Subject: [PATCH 2/2] Pin the close-frame wire bytes with a testable helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole point of this branch — answering a close with the client's own status code instead of a bare 0x88 0x00 — could be deleted from the call site and the suite stayed at 152/152. Every assertion called close-response-code directly; nothing exercised the branch that used it. close-response-frame now maps a received payload to the frame to answer with, so the call site is a single call and the payload -> wire-bytes mapping has a seam a test can hold: an empty payload answers 0x88 0x00, a close of 1003 answers 0x88 0x02 0x03 0xEB, and a close of 1005 answers with 1002's bytes. --- test/websocket.carp | 15 +++++++++++++++ web.carp | 12 +++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/test/websocket.carp b/test/websocket.carp index 68024d9..77ab01a 100644 --- a/test/websocket.carp +++ b/test/websocket.carp @@ -469,6 +469,21 @@ &(WebSocket.close-response-code &(make-close-payload 1000 &[255b 254b])) "a close reason that is not valid UTF-8 is answered with 1007 (§8.1)") + (assert-equal test + &(the (Array Byte) [136b 0b]) + &(WebSocket.close-response-frame &(the (Array Byte) [])) + "an empty close payload answers with a bare 0x88 0x00") + + (assert-equal test + &(the (Array Byte) [136b 2b 3b 235b]) + &(WebSocket.close-response-frame &(make-close-payload 1003 &[])) + "a close of 1003 answers with 0x88 0x02 0x03 0xEB") + + (assert-equal test + &(the (Array Byte) [136b 2b 3b 234b]) + &(WebSocket.close-response-frame &(make-close-payload 1005 &[])) + "a close of 1005 answers with 1002 on the wire") + (assert-equal test "/" &(let [req (Result.unsafe-from-success diff --git a/web.carp b/web.carp index dad34b7..9fe6470 100644 --- a/web.carp +++ b/web.carp @@ -920,6 +920,12 @@ or `Nothing` if no subprotocol was negotiated.") (not (UTF8.valid? &(Array.suffix payload 2))) (Maybe.Just 1007) (Maybe.Just code)))))) + (hidden close-response-frame) + (defn close-response-frame [payload] + (match (close-response-code payload) + (Maybe.Nothing) (encode-close) + (Maybe.Just code) (encode-close-with-code code))) + (doc send "queues a text message for sending on the WebSocket connection.") (defn send [ws msg] (Array.push-back! (WebSocket.outbox ws) (encode-text msg))) @@ -2598,11 +2604,7 @@ fallback.") ¶ms &ws) (Array.push-back! (WebSocket.outbox &ws) - (match (WebSocket.close-response-code (WSFrame.payload &frame)) - (Maybe.Nothing) - (WebSocket.encode-close) - (Maybe.Just code) - (WebSocket.encode-close-with-code code))) + (WebSocket.close-response-frame (WSFrame.payload &frame))) (set! should-close true) (break)) ())