From c4de8d52e7495350fb4300edd5edc687d5b4c2b7 Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Mon, 11 May 2026 17:36:21 +0800 Subject: [PATCH] fix(ws): enforce payload length encoding minimality and MSB constraints Independently reported in parallel by DatanoiseTV --- components/esp_http_server/Kconfig | 5 +- .../esp_http_server/include/esp_http_server.h | 4 - components/esp_http_server/src/httpd_ws.c | 131 +++++-- .../test_apps/main/test_http_server.c | 344 +++++++++++------- 4 files changed, 305 insertions(+), 179 deletions(-) diff --git a/components/esp_http_server/Kconfig b/components/esp_http_server/Kconfig index 8559302ca6f..f606cfb3cae 100644 --- a/components/esp_http_server/Kconfig +++ b/components/esp_http_server/Kconfig @@ -60,9 +60,8 @@ menu "HTTP Server" Sec-WebSocket-Key), rejects frames with reserved RSV bits, reserved or fragmented control opcodes, non-minimal payload length encodings, and frames whose 64-bit length has the MSB set; sends a CLOSE frame on - protocol errors; validates CLOSE frame status codes and UTF-8 reason; - validates UTF-8 text payloads; and blocks outbound data frames once a - CLOSE has been sent or received. + protocol errors; and blocks outbound data frames once a CLOSE has been + sent or received. This option defaults to off in this release cycle so existing deployments see no behavior change. Enable to opt into stricter enforcement; lenient diff --git a/components/esp_http_server/include/esp_http_server.h b/components/esp_http_server/include/esp_http_server.h index 1a5ed4aeba2..f03a5cf95f3 100644 --- a/components/esp_http_server/include/esp_http_server.h +++ b/components/esp_http_server/include/esp_http_server.h @@ -1789,13 +1789,9 @@ typedef enum { HTTPD_WS_TYPE_CONTINUE = 0x0, HTTPD_WS_TYPE_TEXT = 0x1, HTTPD_WS_TYPE_BINARY = 0x2, - HTTPD_WS_TYPE_NON_CTRL_RES = 0x3, /*!< Reserved non-control opcode range start */ - HTTPD_WS_TYPE_NON_CTRL_RES_END = 0x7, /*!< Reserved non-control opcode range end */ HTTPD_WS_TYPE_CLOSE = 0x8, HTTPD_WS_TYPE_PING = 0x9, HTTPD_WS_TYPE_PONG = 0xA, - HTTPD_WS_TYPE_CTRL_RES = 0xB, /*!< Reserved control opcode range start */ - HTTPD_WS_TYPE_CTRL_RES_END = 0xF, /*!< Reserved control opcode range end */ } httpd_ws_type_t; /** diff --git a/components/esp_http_server/src/httpd_ws.c b/components/esp_http_server/src/httpd_ws.c index e08b36e3264..fa6e1fc3d29 100644 --- a/components/esp_http_server/src/httpd_ws.c +++ b/components/esp_http_server/src/httpd_ws.c @@ -56,7 +56,6 @@ static const char *TAG="httpd_ws"; /* RFC 6455 §7.4 close status codes used for protocol-error Close frames */ #define HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR 1002U -#define HTTPD_WS_CLOSE_CODE_INVALID_UTF8 1007U #define HTTPD_WS_CLOSE_CODE_TOO_BIG 1009U /* @@ -353,28 +352,46 @@ static esp_err_t httpd_ws_check_req(httpd_req_t *req) return ESP_OK; } +/* RFC 6455 §5.5: opcodes 0x8-0xA (CLOSE, PING, PONG) are control frames. + * Reserved control opcodes 0xB-0xF are rejected earlier in httpd_ws_get_frame_type, + * so an explicit whitelist is used here instead of a `>= CLOSE` range compare. */ +static inline bool httpd_ws_is_control_opcode(httpd_ws_type_t type) +{ + return type == HTTPD_WS_TYPE_CLOSE || + type == HTTPD_WS_TYPE_PING || + type == HTTPD_WS_TYPE_PONG; +} + +/* Mark the session's pending frame as a CLOSE so the request layer tears the + * socket down after the current request completes. */ +static inline void httpd_ws_mark_closing(struct httpd_req_aux *aux) +{ + aux->ws_final = true; + aux->ws_type = HTTPD_WS_TYPE_CLOSE; +} + /* Send a Close frame with the given status code and mark the session as closing. * Always returns ESP_FAIL so callers can write: return httpd_ws_fail_connection(...). * RFC 6455 §7.1.7 */ -static esp_err_t httpd_ws_fail_connection(httpd_req_t *req, uint16_t close_code, bool send_close) +static esp_err_t httpd_ws_fail_connection(httpd_req_t *req, uint16_t close_code) { + esp_err_t ret = ESP_FAIL; if (!req || !req->aux) { - return ESP_FAIL; + return ret; } struct httpd_req_aux *aux = req->aux; if (!aux->sd) { - return ESP_FAIL; + return ret; } - aux->ws_final = true; - aux->ws_type = HTTPD_WS_TYPE_CLOSE; + httpd_ws_mark_closing(aux); bool already_closing = aux->sd->ws_close; aux->sd->ws_close = true; - if (send_close && aux->sd->ws_handshake_done && !already_closing) { + if (aux->sd->ws_handshake_done && !already_closing) { uint8_t close_payload[2] = { (uint8_t)(close_code >> 8U), (uint8_t)(close_code & 0xffU), @@ -392,7 +409,7 @@ static esp_err_t httpd_ws_fail_connection(httpd_req_t *req, uint16_t close_code, } } - return ESP_FAIL; + return ret; } static esp_err_t httpd_ws_unmask_payload(uint8_t *payload, size_t len, const uint8_t *mask_key, size_t mask_offset) @@ -447,15 +464,15 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t /* Interpret length */ uint8_t init_len = second_byte & HTTPD_WS_LENGTH_BITS; -#if CONFIG_HTTPD_WS_STRICT_RFC6455 +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 /* RFC 6455 §5.5: control frames MUST have payload <= 125 bytes and * therefore MUST NOT use the 16- or 64-bit extended length encodings. */ - if (frame->type >= HTTPD_WS_TYPE_CLOSE && init_len > 125) { + if (httpd_ws_is_control_opcode(frame->type) && init_len > 125) { ESP_LOGE(TAG, LOG_FMT("Invalid control frame length encoding")); - return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR, true); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); } -#endif /* CONFIG_HTTPD_WS_STRICT_RFC6455 */ +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ if (init_len < 126) { /* Case 1: If length is 0-125, then this length bit is 7 bits */ @@ -469,7 +486,15 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t return ESP_FAIL; } - frame->len = ((uint32_t)(length_bytes[0] << 8U) | (length_bytes[1])); + uint16_t length = ((uint16_t)(length_bytes[0] << 8U) | (length_bytes[1])); +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* RFC 6455 §5.2: encodings must be minimal; a value < 126 MUST use 7-bit form. */ + if (length < 126) { + ESP_LOGE(TAG, LOG_FMT("Invalid WS frame length: non-minimal 16-bit encoding")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); + } +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + frame->len = length; } else if (init_len == 127) { /* Case 3: If length is byte 127, then this frame's length bit is 64 bits */ uint8_t length_bytes[8] = { 0 }; @@ -479,14 +504,34 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t return ESP_FAIL; } - frame->len = (((uint64_t)length_bytes[0] << 56U) | - ((uint64_t)length_bytes[1] << 48U) | - ((uint64_t)length_bytes[2] << 40U) | - ((uint64_t)length_bytes[3] << 32U) | - ((uint64_t)length_bytes[4] << 24U) | - ((uint64_t)length_bytes[5] << 16U) | - ((uint64_t)length_bytes[6] << 8U) | - ((uint64_t)length_bytes[7])); +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* RFC 6455 §5.2: MSB must be 0 */ + if (length_bytes[0] & 0x80) { + ESP_LOGE(TAG, LOG_FMT("Invalid WS frame length: MSB must be 0")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); + } +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + + uint64_t length = (uint64_t)length_bytes[0] << 56U | + (uint64_t)length_bytes[1] << 48U | + (uint64_t)length_bytes[2] << 40U | + (uint64_t)length_bytes[3] << 32U | + (uint64_t)length_bytes[4] << 24U | + (uint64_t)length_bytes[5] << 16U | + (uint64_t)length_bytes[6] << 8U | + (uint64_t)length_bytes[7]; +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* Encoding must be minimal; values <= UINT16_MAX MUST use 16-bit form */ + if (length <= UINT16_MAX) { + ESP_LOGE(TAG, LOG_FMT("Invalid WS frame length: non-minimal 64-bit encoding")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); + } +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + if (length > SIZE_MAX) { + ESP_LOGE(TAG, LOG_FMT("Invalid WS frame length: too large for platform")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_TOO_BIG); + } + frame->len = (size_t)length; } frame->left_len = frame->len; @@ -501,9 +546,9 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t /* If the WS frame from client to server is not masked, it should be rejected. * Please refer to RFC6455 Section 5.2 for more details. */ ESP_LOGE(TAG, LOG_FMT("WS frame is not properly masked.")); -#if CONFIG_HTTPD_WS_STRICT_RFC6455 - httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR, true); -#endif /* CONFIG_HTTPD_WS_STRICT_RFC6455 */ +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ return ESP_ERR_INVALID_STATE; } } @@ -580,6 +625,22 @@ esp_err_t httpd_ws_send_frame_async(httpd_handle_t hd, int fd, httpd_ws_frame_t return ESP_ERR_INVALID_ARG; } + struct sock_db *sess = httpd_sess_get(hd, fd); + if (!sess) { + return ESP_ERR_INVALID_ARG; + } + +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* RFC 6455 §1.4 / §5.5.1: once a CLOSE has been sent or received the + * endpoint MUST NOT transmit any further data frames. Only the CLOSE + * frame itself is permitted so that an in-progress fail-the-connection + * or close-handshake response can still be emitted. */ + if (sess->ws_close && frame->type != HTTPD_WS_TYPE_CLOSE) { + ESP_LOGW(TAG, LOG_FMT("Session is closing; refusing non-CLOSE frame (type=0x%02X)"), frame->type); + return ESP_ERR_INVALID_STATE; + } +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + /* Prepare Tx buffer - maximum length is 14, which includes 2 bytes header, 8 bytes length, 4 bytes mask key */ uint8_t tx_len = 0; uint8_t header_buf[10] = {0 }; @@ -590,7 +651,7 @@ esp_err_t httpd_ws_send_frame_async(httpd_handle_t hd, int fd, httpd_ws_frame_t if (frame->len <= 125) { header_buf[1] = frame->len & 0x7fU; /* Length for 7 bits */ tx_len = 2; - } else if (frame->len > 125 && frame->len < UINT16_MAX) { + } else if (frame->len > 125 && frame->len <= UINT16_MAX) { header_buf[1] = 126; /* Length for 16 bits */ header_buf[2] = (frame->len >> 8U) & 0xffU; header_buf[3] = frame->len & 0xffU; @@ -611,11 +672,6 @@ esp_err_t httpd_ws_send_frame_async(httpd_handle_t hd, int fd, httpd_ws_frame_t /* WebSocket server does not required to mask response payload, so leave the MASK bit as 0. */ header_buf[1] &= (~HTTPD_WS_MASK_BIT); - struct sock_db *sess = httpd_sess_get(hd, fd); - if (!sess) { - return ESP_ERR_INVALID_ARG; - } - /* Send off header */ if (sess->send_fn(hd, fd, (const char *)header_buf, tx_len, 0) < 0) { ESP_LOGW(TAG, LOG_FMT("Failed to send WS header")); @@ -660,8 +716,7 @@ esp_err_t httpd_ws_get_frame_type(httpd_req_t *req) /* If we fail to read exactly one byte, this socket FD is invalid or the frame header is incomplete. */ /* Here we mark it as a Close message and close it later. */ ESP_LOGW(TAG, LOG_FMT("Failed to read header byte (socket FD invalid), closing socket now")); - aux->ws_final = true; - aux->ws_type = HTTPD_WS_TYPE_CLOSE; + httpd_ws_mark_closing(aux); return ESP_OK; } @@ -671,13 +726,13 @@ esp_err_t httpd_ws_get_frame_type(httpd_req_t *req) aux->ws_final = (first_byte & HTTPD_WS_FIN_BIT) != 0; aux->ws_type = (first_byte & HTTPD_WS_OPCODE_BITS); -#if CONFIG_HTTPD_WS_STRICT_RFC6455 +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 /* RFC 6455 §5.2: RSV bits MUST be 0 unless an extension has been negotiated. * This implementation does not support extensions, so any set RSV bit is an error. */ if (first_byte & (HTTPD_WS_RSV1_BIT | HTTPD_WS_RSV2_BIT | HTTPD_WS_RSV3_BIT)) { ESP_LOGE(TAG, LOG_FMT("RSV1, RSV2 or RSV3 bits are set, closing connection")); - return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR, true); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); } /* RFC 6455 §5.2: opcodes 0x3–0x7 and 0xB–0xF are reserved and must not be used. */ @@ -691,15 +746,15 @@ esp_err_t httpd_ws_get_frame_type(httpd_req_t *req) break; default: ESP_LOGE(TAG, LOG_FMT("Invalid WS frame type: 0x%02X"), aux->ws_type); - return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR, true); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); } /* RFC 6455 §5.5: control frames MUST NOT be fragmented (FIN bit must be set). */ - if (aux->ws_type >= HTTPD_WS_TYPE_CLOSE && !aux->ws_final) { + if (httpd_ws_is_control_opcode(aux->ws_type) && !aux->ws_final) { ESP_LOGE(TAG, LOG_FMT("Invalid fragmented control frame: 0x%02X"), aux->ws_type); - return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR, true); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); } -#endif /* CONFIG_HTTPD_WS_STRICT_RFC6455 */ +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ /* If userspace requests control frames, do not deal with the control frames */ if (!sd->ws_control_frames) { diff --git a/components/esp_http_server/test_apps/main/test_http_server.c b/components/esp_http_server/test_apps/main/test_http_server.c index 885f51d9068..61c1e790833 100644 --- a/components/esp_http_server/test_apps/main/test_http_server.c +++ b/components/esp_http_server/test_apps/main/test_http_server.c @@ -34,6 +34,17 @@ esp_err_t null_func(httpd_req_t *req) return ESP_OK; } +httpd_uri_t handler_limit_uri (char* path) +{ + httpd_uri_t uri = { + .uri = path, + .method = HTTP_GET, + .handler = null_func, + .user_ctx = NULL, + }; + return uri; +}; + #ifdef CONFIG_HTTPD_WS_SUPPORT static httpd_handle_t start_test_ws_server(uint16_t server_port, uint16_t ctrl_port) { @@ -56,20 +67,7 @@ static httpd_handle_t start_test_ws_server(uint16_t server_port, uint16_t ctrl_p TEST_ASSERT_EQUAL(ESP_OK, httpd_register_uri_handler(hd, &ws_uri)); return hd; } -#endif /* CONFIG_HTTPD_WS_SUPPORT */ -httpd_uri_t handler_limit_uri (char* path) -{ - httpd_uri_t uri = { - .uri = path, - .method = HTTP_GET, - .handler = null_func, - .user_ctx = NULL, - }; - return uri; -}; - -#ifdef CONFIG_HTTPD_WS_SUPPORT static httpd_uri_t handler_limit_ws_uri(char *path, const char *subprotocol) { httpd_uri_t uri = { @@ -593,28 +591,6 @@ TEST_CASE("WS recv failure marks close without dispatching handler", "[HTTP SERV free(hd.hd_req_aux.resp_hdrs); } -#if CONFIG_HTTPD_WS_STRICTER_RFC6455 -TEST_CASE("WS HTTP/1.0 upgrade request returns 400", "[HTTP SERVER][websocket]") -{ - test_case_uses_tcpip(); - httpd_handle_t hd = start_test_ws_server(8091, ESP_HTTPD_DEF_CTRL_PORT + 10); - mock_server_request_t req = { - .data = "GET /ws HTTP/1.0\r\n" - "Host: localhost\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" - "Sec-WebSocket-Version: 13\r\n\r\n", - }; - mock_server_response_t *resp = mock_server_send_request(8091, &req); - TEST_ASSERT_NOT_NULL(resp); - - mock_server_assert_status(resp, 400); - mock_server_response_free(resp); - TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); -} -#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ - /* Regression test: enabling CONFIG_HTTPD_WS_SUPPORT must not reject HTTP/1.0 * traffic on non-WS endpoints. The HTTP/1.1 requirement only applies once * an "Upgrade: websocket" header confirms the request is a WS handshake. */ @@ -636,26 +612,6 @@ TEST_CASE("Non-WS HTTP/1.0 request is not rejected by WS version check", "[HTTP TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); } -#if CONFIG_HTTPD_WS_STRICTER_RFC6455 -TEST_CASE("WS handshake missing Host returns 400", "[HTTP SERVER][websocket]") -{ - test_case_uses_tcpip(); - httpd_handle_t hd = start_test_ws_server(8090, ESP_HTTPD_DEF_CTRL_PORT + 9); - mock_server_request_t req = { - .data = "GET /ws HTTP/1.1\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" - "Sec-WebSocket-Version: 13\r\n\r\n", - }; - mock_server_response_t *resp = mock_server_send_request(8090, &req); - TEST_ASSERT_NOT_NULL(resp); - mock_server_assert_status(resp, 400); - mock_server_response_free(resp); - TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); -} -#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ - TEST_CASE("WS handshake missing Sec-WebSocket-Version returns 400", "[HTTP SERVER][websocket]") { test_case_uses_tcpip(); @@ -695,6 +651,44 @@ TEST_CASE("WS handshake unsupported version returns 426 with Sec-WebSocket-Versi } #if CONFIG_HTTPD_WS_STRICTER_RFC6455 +TEST_CASE("WS HTTP/1.0 upgrade request returns 400", "[HTTP SERVER][websocket]") +{ + test_case_uses_tcpip(); + httpd_handle_t hd = start_test_ws_server(8091, ESP_HTTPD_DEF_CTRL_PORT + 10); + mock_server_request_t req = { + .data = "GET /ws HTTP/1.0\r\n" + "Host: localhost\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n", + }; + mock_server_response_t *resp = mock_server_send_request(8091, &req); + TEST_ASSERT_NOT_NULL(resp); + + mock_server_assert_status(resp, 400); + mock_server_response_free(resp); + TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); +} + +TEST_CASE("WS handshake missing Host returns 400", "[HTTP SERVER][websocket]") +{ + test_case_uses_tcpip(); + httpd_handle_t hd = start_test_ws_server(8090, ESP_HTTPD_DEF_CTRL_PORT + 9); + mock_server_request_t req = { + .data = "GET /ws HTTP/1.1\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n", + }; + mock_server_response_t *resp = mock_server_send_request(8090, &req); + TEST_ASSERT_NOT_NULL(resp); + mock_server_assert_status(resp, 400); + mock_server_response_free(resp); + TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); +} + TEST_CASE("WS handshake invalid Sec-WebSocket-Key returns 400", "[HTTP SERVER][websocket]") { test_case_uses_tcpip(); @@ -713,161 +707,243 @@ TEST_CASE("WS handshake invalid Sec-WebSocket-Key returns 400", "[HTTP SERVER][w mock_server_response_free(resp); TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); } -#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ -#if CONFIG_HTTPD_WS_STRICT_RFC6455 +/* Common fake-session wiring for the strict recv-path tests: a single-socket + * server whose scripted recv feeds `frame` and whose send is captured. */ +static void ws_setup_recv_fixture(struct httpd_data *hd, httpd_req_t *req, + struct httpd_req_aux *aux, struct sock_db *session, + const uint8_t *frame, size_t frame_len) +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + + ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = frame, .len = frame_len }; + memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx)); + + hd->config = config; + hd->config.max_open_sockets = 1; + hd->hd_sd = session; + req->handle = hd; + req->aux = aux; + aux->sd = session; + session->fd = 123; + session->handle = (httpd_handle_t)hd; + session->recv_fn = ws_scripted_recv_override; + session->send_fn = ws_scripted_send_override; + session->ws_handshake_done = true; +} + +/* Asserts the session was marked closing and a 1002 (protocol-error) CLOSE + * frame was emitted on the wire. */ +static void ws_assert_close_1002_sent(const struct sock_db *session) +{ + static const uint8_t expected_reply[] = { 0x88, 0x02, 0x03, 0xEA }; + + TEST_ASSERT_TRUE(session->ws_close); + TEST_ASSERT_EQUAL(sizeof(expected_reply), ws_send_capture_ctx.len); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected_reply, ws_send_capture_ctx.data, sizeof(expected_reply)); +} + TEST_CASE("WS recv RSV bit set sends CLOSE 1002 and marks close", "[HTTP SERVER][websocket]") { static const uint8_t ws_frame[] = { 0xC1 }; /* RSV1=1, FIN=1, opcode TEXT */ - static const uint8_t expected_reply[] = { 0x88, 0x02, 0x03, 0xEA }; - - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); struct httpd_data hd = {0}; httpd_req_t req = {0}; struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = ws_frame, .len = sizeof(ws_frame) }; - memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx)); - - hd.config = config; - hd.config.max_open_sockets = 1; - hd.hd_sd = &session; - req.handle = &hd; - req.aux = &aux; - aux.sd = &session; - session.fd = 123; - session.handle = (httpd_handle_t)&hd; - session.recv_fn = ws_scripted_recv_override; - session.send_fn = ws_scripted_send_override; - session.ws_handshake_done = true; + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_get_frame_type(&req)); - TEST_ASSERT_TRUE(session.ws_close); - TEST_ASSERT_EQUAL(sizeof(expected_reply), ws_send_capture_ctx.len); - TEST_ASSERT_EQUAL_UINT8_ARRAY(expected_reply, ws_send_capture_ctx.data, sizeof(expected_reply)); + ws_assert_close_1002_sent(&session); } TEST_CASE("WS recv reserved non-control opcode sends CLOSE 1002", "[HTTP SERVER][websocket]") { static const uint8_t ws_frame[] = { 0x83 }; /* FIN=1, opcode=0x3 (reserved) */ - + struct httpd_data hd = {0}; httpd_req_t req = {0}; struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = ws_frame, .len = sizeof(ws_frame) }; - - req.aux = &aux; - aux.sd = &session; - session.fd = 123; - session.recv_fn = ws_scripted_recv_override; - session.ws_handshake_done = true; + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_get_frame_type(&req)); TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type); + ws_assert_close_1002_sent(&session); } TEST_CASE("WS recv reserved control opcode sends CLOSE 1002", "[HTTP SERVER][websocket]") { static const uint8_t ws_frame[] = { 0x8B }; /* FIN=1, opcode=0xB (reserved) */ - + struct httpd_data hd = {0}; httpd_req_t req = {0}; struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = ws_frame, .len = sizeof(ws_frame) }; - - req.aux = &aux; - aux.sd = &session; - session.fd = 123; - session.recv_fn = ws_scripted_recv_override; - session.ws_handshake_done = true; + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_get_frame_type(&req)); TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type); + ws_assert_close_1002_sent(&session); } TEST_CASE("WS recv fragmented control frame sends CLOSE 1002", "[HTTP SERVER][websocket]") { static const uint8_t ws_frame[] = { 0x09 }; /* FIN=0, opcode=PING */ - + struct httpd_data hd = {0}; httpd_req_t req = {0}; struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = ws_frame, .len = sizeof(ws_frame) }; - - req.aux = &aux; - aux.sd = &session; - session.fd = 123; - session.recv_fn = ws_scripted_recv_override; - session.ws_handshake_done = true; + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_get_frame_type(&req)); TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type); + ws_assert_close_1002_sent(&session); } -#endif /* CONFIG_HTTPD_WS_STRICT_RFC6455 */ -#if CONFIG_HTTPD_WS_STRICT_RFC6455 TEST_CASE("WS recv unmasked frame sends CLOSE 1002 and marks close", "[HTTP SERVER][websocket]") { - /* Second byte 0x02: MASK=0, payload len=2 */ - static const uint8_t ws_frame[] = { 0x82, 0x02 }; - static const uint8_t expected_reply[] = { 0x88, 0x02, 0x03, 0xEA }; - - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + /* Length byte 0x02: MASK=0, payload len=2. recv_frame reads this as its + * first byte (the opcode byte is taken from aux), so it exercises the + * unmasked-client-frame rejection rather than the mask-key path. */ + static const uint8_t ws_frame[] = { 0x02 }; struct httpd_data hd = {0}; httpd_req_t req = {0}; struct httpd_req_aux aux = {0}; struct sock_db session = {0}; httpd_ws_frame_t frame = {0}; - ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = ws_frame, .len = sizeof(ws_frame) }; - memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx)); - - hd.config = config; - hd.config.max_open_sockets = 1; - hd.hd_sd = &session; - req.handle = &hd; - req.aux = &aux; - aux.sd = &session; + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); aux.ws_type = HTTPD_WS_TYPE_BINARY; aux.ws_final = true; - session.fd = 123; - session.handle = (httpd_handle_t)&hd; - session.recv_fn = ws_scripted_recv_override; - session.send_fn = ws_scripted_send_override; - session.ws_handshake_done = true; TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, httpd_ws_recv_frame(&req, &frame, 0)); - TEST_ASSERT_TRUE(session.ws_close); - TEST_ASSERT_EQUAL(sizeof(expected_reply), ws_send_capture_ctx.len); - TEST_ASSERT_EQUAL_UINT8_ARRAY(expected_reply, ws_send_capture_ctx.data, sizeof(expected_reply)); + ws_assert_close_1002_sent(&session); } TEST_CASE("WS recv control frame with payload > 125 sends CLOSE 1002", "[HTTP SERVER][websocket]") { /* PING (0x89), MASK=1 (0x80), length=126 (0x7E) — invalid extended length for control */ static const uint8_t ws_frame[] = { 0x89, 0xFE }; - + struct httpd_data hd = {0}; httpd_req_t req = {0}; struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_scripted_recv_ctx = (ws_scripted_recv_ctx_t){ .data = ws_frame, .len = sizeof(ws_frame) }; - - req.aux = &aux; - aux.sd = &session; - session.fd = 123; - session.recv_fn = ws_scripted_recv_override; - session.ws_handshake_done = true; + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); session.ws_control_frames = false; TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, httpd_ws_get_frame_type(&req)); TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type); + ws_assert_close_1002_sent(&session); +} + +TEST_CASE("WS recv rejects non-minimal 16-bit payload length encoding", "[HTTP SERVER][websocket]") +{ + /* 0xFE = MASK=1 len=126; 0x00 0x7D = 125, which must use 7-bit form */ + static const uint8_t ws_frame[] = { 0xFE, 0x00, 0x7D }; + struct httpd_data hd = {0}; + httpd_req_t req = {0}; + struct httpd_req_aux aux = {0}; + struct sock_db session = {0}; + httpd_ws_frame_t frame = {0}; + + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); + aux.ws_type = HTTPD_WS_TYPE_TEXT; + aux.ws_final = true; + + TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_recv_frame(&req, &frame, 0)); + TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type); + ws_assert_close_1002_sent(&session); +} + +TEST_CASE("WS recv rejects non-minimal 64-bit payload length encoding", "[HTTP SERVER][websocket]") +{ + /* 0xFF = MASK=1 len=127; value 65535 must use 16-bit form */ + static const uint8_t ws_frame[] = { 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF }; + struct httpd_data hd = {0}; + httpd_req_t req = {0}; + struct httpd_req_aux aux = {0}; + struct sock_db session = {0}; + httpd_ws_frame_t frame = {0}; + + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); + aux.ws_type = HTTPD_WS_TYPE_TEXT; + aux.ws_final = true; + + TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_recv_frame(&req, &frame, 0)); + TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type); + ws_assert_close_1002_sent(&session); +} + +TEST_CASE("WS send refuses non-CLOSE frame once session is closing", "[HTTP SERVER][websocket]") +{ + /* RFC 6455 §5.5.1: after a CLOSE is sent/received, only a CLOSE may follow. */ + static const uint8_t payload[] = { 0x41, 0x42 }; + static const uint8_t expected_close[] = { 0x88, 0x00 }; /* CLOSE, zero-length */ + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + struct httpd_data hd = {0}; + struct sock_db session = {0}; + httpd_ws_frame_t data_frame = { + .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t *)payload, + .len = sizeof(payload), + }; + httpd_ws_frame_t close_frame = { + .type = HTTPD_WS_TYPE_CLOSE, + .payload = NULL, + .len = 0, + }; + + memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx)); + + hd.config = config; + hd.config.max_open_sockets = 1; + hd.hd_sd = &session; + session.fd = 123; + session.handle = (httpd_handle_t)&hd; + session.send_fn = ws_scripted_send_override; + session.ws_close = true; /* a CLOSE has already been sent/received */ + + /* A data frame must be refused and nothing may go on the wire. */ + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, httpd_ws_send_frame_async(&hd, session.fd, &data_frame)); + TEST_ASSERT_EQUAL(0, ws_send_capture_ctx.len); + + /* The CLOSE frame itself is still permitted. */ + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_send_frame_async(&hd, session.fd, &close_frame)); + TEST_ASSERT_EQUAL(sizeof(expected_close), ws_send_capture_ctx.len); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected_close, ws_send_capture_ctx.data, sizeof(expected_close)); +} +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + +TEST_CASE("WS send uses 16-bit length encoding for exactly 65535-byte payload", "[HTTP SERVER][websocket]") +{ + static const uint8_t expected_header[] = { 0x82, 0x7E, 0xFF, 0xFF }; + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + struct httpd_data hd = {0}; + struct sock_db session = {0}; + httpd_ws_frame_t frame = { + .type = HTTPD_WS_TYPE_BINARY, + .payload = NULL, + .len = UINT16_MAX, + }; + + memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx)); + + hd.config = config; + hd.config.max_open_sockets = 1; + hd.hd_sd = &session; + session.fd = 123; + session.handle = (httpd_handle_t)&hd; + session.send_fn = ws_scripted_send_override; + + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_send_frame_async(&hd, session.fd, &frame)); + TEST_ASSERT_EQUAL(sizeof(expected_header), ws_send_capture_ctx.len); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected_header, ws_send_capture_ctx.data, sizeof(expected_header)); } -#endif /* CONFIG_HTTPD_WS_STRICT_RFC6455 */ #endif /* CONFIG_HTTPD_WS_SUPPORT */ /********* URL query / header pointer-accessor tests *********