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..77ab01a 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,123 @@ (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 + &(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 5ccbf22..9fe6470 100644 --- a/web.carp +++ b/web.carp @@ -900,6 +900,32 @@ 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)))))) + + (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))) @@ -2578,7 +2604,7 @@ fallback.") ¶ms &ws) (Array.push-back! (WebSocket.outbox &ws) - (WebSocket.encode-close)) + (WebSocket.close-response-frame (WSFrame.payload &frame))) (set! should-close true) (break)) ())