diff --git a/components/esp_http_server/Kconfig b/components/esp_http_server/Kconfig index f606cfb3cae..3c0483a4a7d 100644 --- a/components/esp_http_server/Kconfig +++ b/components/esp_http_server/Kconfig @@ -60,8 +60,11 @@ 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; and blocks outbound data frames once a CLOSE has been - sent or received. + 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. + + UTF-8 is validated for a complete, unfragmented text frame only. 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 bcebe2dd1aa..7431fda7276 100644 --- a/components/esp_http_server/include/esp_http_server.h +++ b/components/esp_http_server/include/esp_http_server.h @@ -1857,6 +1857,13 @@ typedef void (*transfer_complete_cb)(esp_err_t err, int socket, void *arg); * * @note Calling httpd_ws_recv_frame() with max_len as 0 will give actual frame size in pkt->len. * The user can dynamically allocate space for pkt->payload as per this length and call httpd_ws_recv_frame() again to get the actual data. + * + * @note Fragmented messages (RFC 6455 §5.4) are not supported. Each frame is + * returned on its own; the library never joins the fragments of one + * message, and it does not validate the fragment sequence. Read pkt->final + * and pkt->type to detect a fragment, and join the payloads in the + * application. UTF-8 validation of a TEXT message that arrives in + * fragments is the caller's responsibility; see httpd_ws_validate_utf8(). * Please refer to the corresponding example for usage. * * @param[in] req Current request @@ -1878,6 +1885,13 @@ esp_err_t httpd_ws_recv_frame(httpd_req_t *req, httpd_ws_frame_t *pkt, size_t ma * The user can dynamically allocate space for pkt->payload or user defined chunk size and call httpd_ws_recv_frame_part() again to get the actual data. * In contrast to httpd_ws_recv_frame, this method is able to read frame payload partially. The amount of data that is yet to be received is stored in pkt->left_len * + * @note UTF-8 validation required by RFC 6455 §8.1 for TEXT frames is only + * performed by the library when a whole frame is consumed in a single + * call (which includes a httpd_ws_recv_frame_part() call whose max_len + * covers the entire payload). Callers that assemble a TEXT payload + * across multiple calls are responsible for validating the assembled + * buffer themselves; see httpd_ws_validate_utf8(). + * * @param[in] req Current request * @param[out] pkt WebSocket packet * @param[in] max_len Maximum length for receive @@ -1889,6 +1903,22 @@ esp_err_t httpd_ws_recv_frame(httpd_req_t *req, httpd_ws_frame_t *pkt, size_t ma */ esp_err_t httpd_ws_recv_frame_part(httpd_req_t *req, httpd_ws_frame_t *pkt, size_t max_len); +/** + * @brief Validate that a byte buffer is well-formed UTF-8 per RFC 3629. + * + * Intended for application code that assembles a WebSocket TEXT message + * across multiple httpd_ws_recv_frame_part() calls and needs to enforce + * RFC 6455 §8.1 on the assembled payload before processing it. + * + * @param[in] data Pointer to the buffer to validate. May be NULL only if len is 0. + * @param[in] len Length of the buffer in bytes. + * @return + * - ESP_OK : Buffer is valid UTF-8 (an empty buffer is always valid). + * - ESP_ERR_INVALID_ARG : data is NULL with non-zero len, or the buffer is not + * well-formed UTF-8 (overlong, surrogate, or beyond U+10FFFF). + */ +esp_err_t httpd_ws_validate_utf8(const uint8_t *data, size_t len); + /** * @brief Construct and send a WebSocket frame * @param[in] req Current request diff --git a/components/esp_http_server/src/esp_httpd_priv.h b/components/esp_http_server/src/esp_httpd_priv.h index c612ea61603..0dee4451e13 100644 --- a/components/esp_http_server/src/esp_httpd_priv.h +++ b/components/esp_http_server/src/esp_httpd_priv.h @@ -87,6 +87,7 @@ struct sock_db { #ifdef CONFIG_HTTPD_WS_SUPPORT bool ws_handshake_done; /*!< True if it has done WebSocket handshake (if this socket is a valid WS) */ bool ws_close; /*!< Set to true to close the socket later (when WS Close frame received) */ + bool ws_close_sent; /*!< Set to true once this endpoint has sent its own WS Close frame */ esp_err_t (*ws_handler)(httpd_req_t *r); /*!< WebSocket handler, leave to null if it's not WebSocket */ bool ws_control_frames; /*!< WebSocket flag indicating that control frames should be passed to user handlers */ esp_err_t (*ws_control_handler)(httpd_req_t *r, const httpd_ws_frame_t *frame); /*!< Dedicated WebSocket control-frame handler, NULL if not used */ diff --git a/components/esp_http_server/src/httpd_ws.c b/components/esp_http_server/src/httpd_ws.c index 8fde6f6caff..70c9c298fc0 100644 --- a/components/esp_http_server/src/httpd_ws.c +++ b/components/esp_http_server/src/httpd_ws.c @@ -56,6 +56,7 @@ 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 /* @@ -370,6 +371,16 @@ static inline void httpd_ws_mark_closing(struct httpd_req_aux *aux) aux->ws_type = HTTPD_WS_TYPE_CLOSE; } +/* Record that this endpoint has put its own CLOSE frame on the wire. Called from + * the single send path, so an application that replies to a CLOSE itself is + * tracked as well. */ +static inline void httpd_ws_mark_close_sent(struct sock_db *sess, httpd_ws_type_t type) +{ + if (type == HTTPD_WS_TYPE_CLOSE) { + sess->ws_close_sent = true; + } +} + /* 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 @@ -388,10 +399,10 @@ static esp_err_t httpd_ws_fail_connection(httpd_req_t *req, uint16_t close_code) httpd_ws_mark_closing(aux); - bool already_closing = aux->sd->ws_close; + bool close_already_sent = aux->sd->ws_close_sent; aux->sd->ws_close = true; - if (aux->sd->ws_handshake_done && !already_closing) { + if (aux->sd->ws_handshake_done && !close_already_sent) { uint8_t close_payload[2] = { (uint8_t)(close_code >> 8U), (uint8_t)(close_code & 0xffU), @@ -412,6 +423,86 @@ static esp_err_t httpd_ws_fail_connection(httpd_req_t *req, uint16_t close_code) return ret; } +esp_err_t httpd_ws_validate_utf8(const uint8_t *data, size_t len) +{ + if (data == NULL && len != 0) { + return ESP_ERR_INVALID_ARG; + } + size_t idx = 0; + while (idx < len) { + uint8_t c = data[idx++]; + if (c <= 0x7F) { + continue; + } + uint8_t trail = 0; + uint8_t lo = 0x80, hi = 0xBF; + if (c >= 0xC2 && c <= 0xDF) { + trail = 1; + } else if (c == 0xE0) { + trail = 2; + lo = 0xA0; + } else if (c == 0xED) { + trail = 2; + hi = 0x9F; + } else if (c >= 0xE1 && c <= 0xEF) { + trail = 2; + } else if (c == 0xF0) { + trail = 3; + lo = 0x90; + } else if (c == 0xF4) { + trail = 3; + hi = 0x8F; + } else if (c >= 0xF1 && c <= 0xF3) { + trail = 3; + } else { + return ESP_ERR_INVALID_ARG; + } + for (uint8_t i = 0; i < trail; i++) { + if (idx >= len || data[idx] < lo || data[idx] > hi) { + return ESP_ERR_INVALID_ARG; + } + idx++; + lo = 0x80; + hi = 0xBF; + } + } + return ESP_OK; +} + +static bool httpd_ws_is_valid_close_code(uint16_t code) +{ + if (code >= 1000 && code <= 1014 && code != 1004 && code != 1005 && code != 1006) { + return true; + } + return code >= 3000 && code <= 4999; +} + +/* Validates the payload of a CLOSE frame (RFC 6455 §5.5.1 / §7.4 / §8.1). */ +static esp_err_t httpd_ws_validate_close_frame(httpd_req_t *req, const httpd_ws_frame_t *frame) +{ + if (frame->type != HTTPD_WS_TYPE_CLOSE) { + return ESP_OK; + } + /* A one-byte body is never valid. */ + if (frame->len == 1) { + ESP_LOGE(TAG, LOG_FMT("Invalid CLOSE frame payload length")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); + } + if (frame->len >= 2) { + uint16_t close_code = ((uint16_t)frame->payload[0] << 8U) | frame->payload[1]; + if (!httpd_ws_is_valid_close_code(close_code)) { + ESP_LOGE(TAG, LOG_FMT("Invalid CLOSE frame code: %u"), close_code); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR); + } + if (frame->len > 2 && httpd_ws_validate_utf8(frame->payload + 2, frame->len - 2) != ESP_OK) { + ESP_LOGE(TAG, LOG_FMT("Invalid CLOSE frame UTF-8 reason")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_INVALID_UTF8); + } + } + return ESP_OK; +} +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + static esp_err_t httpd_ws_unmask_payload(uint8_t *payload, size_t len, const uint8_t *mask_key, size_t mask_offset) { if (len < 1 || !payload) { @@ -598,6 +689,20 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t /* Unmask payload */ httpd_ws_unmask_payload(frame->payload, offset, aux->mask_key, mask_offset); +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* Validate complete frames (left_len == 0 and all bytes unmasked). */ + if (frame->left_len == 0 && offset == frame->len) { + /* RFC 6455 §8.1: text frames must carry valid UTF-8. */ + if (frame->type == HTTPD_WS_TYPE_TEXT && frame->final && + httpd_ws_validate_utf8(frame->payload, frame->len) != ESP_OK) { + ESP_LOGE(TAG, LOG_FMT("Invalid WS text payload UTF-8")); + return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_INVALID_UTF8); + } + /* Validate CLOSE frame payload and UTF-8 reason (Issues 16, 19). */ + return httpd_ws_validate_close_frame(req, frame); + } +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ + return ESP_OK; } @@ -672,6 +777,22 @@ 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); + /* Send a short frame as a single write. A CLOSE reply is followed at once by + * the socket teardown, and if unread request data is still pending the stack + * aborts the connection with an RST. That RST can discard a second, separate + * write, so the peer sees a header that promises a payload it never gets. */ + if (frame->len > 0 && frame->payload != NULL && frame->len <= HTTPD_WS_CONTROL_PAYLOAD_MAX) { + uint8_t frame_out[sizeof(header_buf) + HTTPD_WS_CONTROL_PAYLOAD_MAX]; + memcpy(frame_out, header_buf, tx_len); + memcpy(frame_out + tx_len, frame->payload, frame->len); + if (sess->send_fn(hd, fd, (const char *)frame_out, tx_len + frame->len, 0) < 0) { + ESP_LOGW(TAG, LOG_FMT("Failed to send WS frame")); + return ESP_FAIL; + } + httpd_ws_mark_close_sent(sess, frame->type); + return ESP_OK; + } + /* 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")); @@ -686,6 +807,7 @@ esp_err_t httpd_ws_send_frame_async(httpd_handle_t hd, int fd, httpd_ws_frame_t } } + httpd_ws_mark_close_sent(sess, frame->type); return ESP_OK; } @@ -713,10 +835,14 @@ esp_err_t httpd_ws_reply_to_control_frame(httpd_req_t *req, httpd_ws_frame_t *fr frame->type = HTTPD_WS_TYPE_PONG; return httpd_ws_send_frame(req, frame); case HTTPD_WS_TYPE_CLOSE: - /* Reply to a CLOSE with an empty CLOSE (RFC 6455 §5.5.1) */ + /* Reply to a CLOSE with a CLOSE (RFC 6455 §5.5.1). Strict mode echoes the + * received status code and reason, as §5.5.1 recommends. Lenient + * (pre-strict) behavior replies with an empty CLOSE. */ ESP_LOGD(TAG, LOG_FMT("Got a WS CLOSE frame, Replying CLOSE...")); +#if !CONFIG_HTTPD_WS_STRICTER_RFC6455 frame->len = 0; frame->payload = NULL; +#endif /* !CONFIG_HTTPD_WS_STRICTER_RFC6455 */ return httpd_ws_send_frame(req, frame); default: /* PONG and any other control frame require no reply */ 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 778ca940b33..fc2f6900648 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 @@ -742,6 +742,33 @@ TEST_CASE("WS handshake unsupported version returns 426 with Sec-WebSocket-Versi TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); } +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)); +} + #if CONFIG_HTTPD_WS_STRICTER_RFC6455 TEST_CASE("WS HTTP/1.0 upgrade request returns 400", "[HTTP SERVER][websocket]") { @@ -824,11 +851,11 @@ static void ws_setup_recv_fixture(struct httpd_data *hd, httpd_req_t *req, 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) +/* Asserts the session was marked closing and a CLOSE frame carrying the given + * status code was emitted on the wire. */ +static void ws_assert_close_sent(const struct sock_db *session, uint16_t code) { - static const uint8_t expected_reply[] = { 0x88, 0x02, 0x03, 0xEA }; + const uint8_t expected_reply[] = { 0x88, 0x02, (uint8_t)(code >> 8U), (uint8_t)(code & 0xFFU) }; TEST_ASSERT_TRUE(session->ws_close); TEST_ASSERT_EQUAL(sizeof(expected_reply), ws_send_capture_ctx.len); @@ -846,7 +873,7 @@ TEST_CASE("WS recv RSV bit set sends CLOSE 1002 and marks close", "[HTTP SERVER] ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_get_frame_type(&req)); - ws_assert_close_1002_sent(&session); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv reserved non-control opcode sends CLOSE 1002", "[HTTP SERVER][websocket]") @@ -861,7 +888,7 @@ TEST_CASE("WS recv reserved non-control opcode sends CLOSE 1002", "[HTTP SERVER] 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); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv reserved control opcode sends CLOSE 1002", "[HTTP SERVER][websocket]") @@ -876,7 +903,7 @@ TEST_CASE("WS recv reserved control opcode sends CLOSE 1002", "[HTTP SERVER][web 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); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv fragmented control frame sends CLOSE 1002", "[HTTP SERVER][websocket]") @@ -891,7 +918,7 @@ TEST_CASE("WS recv fragmented control frame sends CLOSE 1002", "[HTTP SERVER][we 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); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv unmasked frame sends CLOSE 1002 and marks close", "[HTTP SERVER][websocket]") @@ -911,7 +938,7 @@ TEST_CASE("WS recv unmasked frame sends CLOSE 1002 and marks close", "[HTTP SERV aux.ws_final = true; TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, httpd_ws_recv_frame(&req, &frame, 0)); - ws_assert_close_1002_sent(&session); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv control frame with payload > 125 sends CLOSE 1002", "[HTTP SERVER][websocket]") @@ -928,7 +955,7 @@ TEST_CASE("WS recv control frame with payload > 125 sends CLOSE 1002", "[HTTP SE 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); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv rejects non-minimal 16-bit payload length encoding", "[HTTP SERVER][websocket]") @@ -947,7 +974,7 @@ TEST_CASE("WS recv rejects non-minimal 16-bit payload length encoding", "[HTTP S 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); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS recv rejects non-minimal 64-bit payload length encoding", "[HTTP SERVER][websocket]") @@ -966,7 +993,7 @@ TEST_CASE("WS recv rejects non-minimal 64-bit payload length encoding", "[HTTP S 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); + ws_assert_close_sent(&session, 1002); } TEST_CASE("WS send refuses non-CLOSE frame once session is closing", "[HTTP SERVER][websocket]") @@ -1008,147 +1035,165 @@ TEST_CASE("WS send refuses non-CLOSE frame once session is closing", "[HTTP SERV 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]") +TEST_CASE("WS auto CLOSE reply echoes received payload", "[HTTP SERVER][websocket]") { - static const uint8_t expected_header[] = { 0x82, 0x7E, 0xFF, 0xFF }; + /* CLOSE frame: FIN|CLOSE, MASK=1 len=4, zero mask, payload = 1000 + "OK" */ + static const uint8_t ws_frame[] = { + 0x88, 0x84, 0x00, 0x00, 0x00, 0x00, 0x03, 0xE8, 'O', 'K' + }; + static const uint8_t expected_reply[] = { 0x88, 0x04, 0x03, 0xE8, 'O', 'K' }; - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); struct httpd_data hd = {0}; + struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - httpd_ws_frame_t frame = { - .type = HTTPD_WS_TYPE_BINARY, - .payload = NULL, - .len = UINT16_MAX, + httpd_req_t req = {0}; + + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); + + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_get_frame_type(&req)); + 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 rejects CLOSE frame with 1-byte payload", "[HTTP SERVER][websocket]") +{ + /* CLOSE with 1-byte body — always invalid */ + static const uint8_t ws_frame[] = { 0x88, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00 }; + + struct httpd_data hd = {0}; + httpd_req_t req = {0}; + struct httpd_req_aux aux = {0}; + struct sock_db session = {0}; + + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); + + 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_sent(&session, 1002); +} + +TEST_CASE("WS recv rejects CLOSE frame with invalid close code", "[HTTP SERVER][websocket]") +{ + /* Close code 1005 is reserved and must not appear on the wire */ + static const uint8_t ws_frame[] = { 0x88, 0x82, 0x00, 0x00, 0x00, 0x00, 0x03, 0xED }; + + struct httpd_data hd = {0}; + httpd_req_t req = {0}; + struct httpd_req_aux aux = {0}; + struct sock_db session = {0}; + + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); + + 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_sent(&session, 1002); +} + +TEST_CASE("WS recv rejects CLOSE frame with invalid UTF-8 reason", "[HTTP SERVER][websocket]") +{ + /* Code 1000 (valid) but reason is overlong UTF-8 0xC0 0xAF */ + static const uint8_t ws_frame[] = { + 0x88, 0x84, 0x00, 0x00, 0x00, 0x00, 0x03, 0xE8, 0xC0, 0xAF }; - 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)); -} - -TEST_CASE("WS control handler receives PING and server replies PONG", "[HTTP SERVER][websocket]") -{ - /* Masked (zero-key) PING carrying a 2-byte payload "Hi". */ - static const uint8_t ping_frame[] = { 0x89, 0x82, 0x00, 0x00, 0x00, 0x00, 'H', 'i' }; struct httpd_data hd = {0}; + httpd_req_t req = {0}; + struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_unit_ctx_init(&hd, &session, ping_frame, sizeof(ping_frame)); - session.ws_control_frames = true; - session.ws_control_handler = ws_control_handler_spy; - esp_err_t ret = httpd_req_new(&hd, &session); + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); - TEST_ASSERT_EQUAL(ESP_OK, ret); - TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls); - TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_PING, s_ws_control_seen_type); - TEST_ASSERT_EQUAL(2, s_ws_control_seen_len); - TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls); /* control frame must not reach data handler */ - TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len); - TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* FIN | PONG */ - TEST_ASSERT_FALSE(session.ws_close); - - free(hd.hd_req_aux.resp_hdrs); + 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_sent(&session, 1007); } -TEST_CASE("WS control handler receives CLOSE and server replies CLOSE", "[HTTP SERVER][websocket]") +TEST_CASE("WS recv rejects invalid UTF-8 in text frame", "[HTTP SERVER][websocket]") { - static const uint8_t close_frame[] = { 0x88, 0x80, 0x00, 0x00, 0x00, 0x00 }; + /* Binary frame mismarked as TEXT; payload 0xC0 0xAF is an overlong encoding */ + static const uint8_t ws_frame[] = { 0x82, 0x82, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xAF }; + struct httpd_data hd = {0}; + httpd_req_t req = {0}; + struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_unit_ctx_init(&hd, &session, close_frame, sizeof(close_frame)); - session.ws_control_frames = true; - session.ws_control_handler = ws_control_handler_spy; + httpd_ws_frame_t frame = {0}; + uint8_t payload[2] = {0}; - esp_err_t ret = httpd_req_new(&hd, &session); + frame.payload = payload; + 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_OK, ret); - TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls); - TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, s_ws_control_seen_type); - TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls); - TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len); - TEST_ASSERT_EQUAL_HEX8(0x88, s_ws_sent[0]); /* FIN | CLOSE */ - TEST_ASSERT_TRUE(session.ws_close); /* server marked the session for close */ - - free(hd.hd_req_aux.resp_hdrs); + TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_recv_frame(&req, &frame, 2)); + ws_assert_close_sent(&session, 1007); } -TEST_CASE("WS control handler receives PONG with no reply", "[HTTP SERVER][websocket]") +TEST_CASE("WS recv accepts CLOSE frame with IANA close code 1013", "[HTTP SERVER][websocket]") { - static const uint8_t pong_frame[] = { 0x8A, 0x80, 0x00, 0x00, 0x00, 0x00 }; + /* CLOSE, MASK=1 len=2, zero mask, payload code 1013 (0x03F5) */ + static const uint8_t ws_frame[] = { + 0x88, 0x82, 0x00, 0x00, 0x00, 0x00, 0x03, 0xF5 + }; + /* Strict mode echoes the received CLOSE payload back to the client. */ + static const uint8_t expected_reply[] = { 0x88, 0x02, 0x03, 0xF5 }; + struct httpd_data hd = {0}; + struct httpd_req_aux aux = {0}; struct sock_db session = {0}; - ws_unit_ctx_init(&hd, &session, pong_frame, sizeof(pong_frame)); - session.ws_control_frames = true; - session.ws_control_handler = ws_control_handler_spy; + httpd_req_t req = {0}; - esp_err_t ret = httpd_req_new(&hd, &session); + ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame)); - TEST_ASSERT_EQUAL(ESP_OK, ret); - TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls); - TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_PONG, s_ws_control_seen_type); - TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls); - TEST_ASSERT_EQUAL(0, s_ws_sent_len); /* a PONG is never answered */ - TEST_ASSERT_FALSE(session.ws_close); - - free(hd.hd_req_aux.resp_hdrs); + /* Validator accepts 1013 → auto CLOSE-echo path runs successfully. */ + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_get_frame_type(&req)); + 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 without control handler auto-replies PING (backward compatible)", "[HTTP SERVER][websocket]") +TEST_CASE("httpd_ws_validate_utf8 accepts valid and rejects malformed UTF-8", "[HTTP SERVER][websocket]") { - /* Mode 1: no control handler, flag off -> server must still auto-reply PONG and - * must not dispatch the PING to the data handler (unchanged legacy behavior). */ - static const uint8_t ping_frame[] = { 0x89, 0x80, 0x00, 0x00, 0x00, 0x00 }; - struct httpd_data hd = {0}; - struct sock_db session = {0}; - ws_unit_ctx_init(&hd, &session, ping_frame, sizeof(ping_frame)); - session.ws_control_frames = false; - session.ws_control_handler = NULL; + /* Empty buffer is valid; NULL with len=0 is also valid. */ + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_validate_utf8(NULL, 0)); + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_validate_utf8((const uint8_t *)"", 0)); - esp_err_t ret = httpd_req_new(&hd, &session); + /* NULL with non-zero length is rejected. */ + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(NULL, 4)); - TEST_ASSERT_EQUAL(ESP_OK, ret); - TEST_ASSERT_EQUAL(0, s_ws_control_handler_calls); - TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls); - TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len); - TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* auto PONG */ + /* Valid ASCII and well-formed multi-byte sequences. */ + static const uint8_t valid_ascii[] = { 'h', 'e', 'l', 'l', 'o' }; + static const uint8_t valid_two[] = { 0xC3, 0xB1 }; /* U+00F1 */ + static const uint8_t valid_three[] = { 0xE2, 0x82, 0xAC }; /* U+20AC */ + static const uint8_t valid_four[] = { 0xF0, 0x9F, 0x98, 0x80 }; /* U+1F600 */ + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_validate_utf8(valid_ascii, sizeof(valid_ascii))); + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_validate_utf8(valid_two, sizeof(valid_two))); + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_validate_utf8(valid_three, sizeof(valid_three))); + TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_validate_utf8(valid_four, sizeof(valid_four))); - free(hd.hd_req_aux.resp_hdrs); + /* Overlong 2-byte (C0 80 would encode U+0000). */ + static const uint8_t overlong2[] = { 0xC0, 0x80 }; + /* Overlong 3-byte (E0 80 80). */ + static const uint8_t overlong3[] = { 0xE0, 0x80, 0x80 }; + /* UTF-16 surrogate (ED A0 80 = U+D800). */ + static const uint8_t surrogate[] = { 0xED, 0xA0, 0x80 }; + /* Beyond U+10FFFF (F4 90 80 80). */ + static const uint8_t beyond_max[] = { 0xF4, 0x90, 0x80, 0x80 }; + /* Invalid lead byte F5. */ + static const uint8_t bad_lead[] = { 0xF5, 0x80, 0x80, 0x80 }; + /* Stray continuation byte without a lead. */ + static const uint8_t stray_trail[] = { 0x80 }; + /* Truncated 2-byte sequence (C3 without trail). */ + static const uint8_t truncated[] = { 0xC3 }; + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(overlong2, sizeof(overlong2))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(overlong3, sizeof(overlong3))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(surrogate, sizeof(surrogate))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(beyond_max, sizeof(beyond_max))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(bad_lead, sizeof(bad_lead))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(stray_trail, sizeof(stray_trail))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(truncated, sizeof(truncated))); } - -TEST_CASE("WS control handler error still replies then closes socket", "[HTTP SERVER][websocket]") -{ - /* A PING is used so ws_close stays false and cleanup does not touch the fake - * control socket. The handler fails, but the server must still send the PONG, - * and httpd_req_new() must propagate the error so the caller closes the socket. */ - static const uint8_t ping_frame[] = { 0x89, 0x80, 0x00, 0x00, 0x00, 0x00 }; - struct httpd_data hd = {0}; - struct sock_db session = {0}; - ws_unit_ctx_init(&hd, &session, ping_frame, sizeof(ping_frame)); - session.ws_control_frames = true; - session.ws_control_handler = ws_control_handler_spy; - s_ws_control_ret = ESP_FAIL; - - esp_err_t ret = httpd_req_new(&hd, &session); - - TEST_ASSERT_EQUAL(ESP_FAIL, ret); /* error propagated to caller */ - TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls); - TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len); - TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* reply sent despite handler error */ - - free(hd.hd_req_aux.resp_hdrs); -} - +#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */ #endif /* CONFIG_HTTPD_WS_SUPPORT */ /********* URL query / header pointer-accessor tests ********* diff --git a/docs/en/api-reference/protocols/esp_http_server.rst b/docs/en/api-reference/protocols/esp_http_server.rst index 86eaa5e4d38..6a7be0a3e39 100644 --- a/docs/en/api-reference/protocols/esp_http_server.rst +++ b/docs/en/api-reference/protocols/esp_http_server.rst @@ -139,6 +139,20 @@ To use the WebSocket post-handshake callback, you must enable :menuitem:`CONFIG_ httpd_register_uri_handler(server, &ws); +WebSocket Message Fragmentation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The server does not support fragmented WebSocket messages, as `RFC 6455, section 5.4 `_ defines them. An application that must accept or send fragments must apply the rules below itself. + +On receive, the server passes each frame to the handler on its own. It does not join the fragments of one message. A handler that gets a TEXT message in three fragments sees three separate frames. Use the ``final`` and ``fragmented`` fields of :cpp:type:`httpd_ws_frame_t` to detect a fragment, and join the payloads in the application. + +The server does not validate the fragment sequence. It accepts a CONTINUE frame that continues no message. It also accepts a new TEXT or BINARY frame while a fragmented message is still open. RFC 6455 requires a close with status code 1002 in both cases. + +:menuitem:`CONFIG_HTTPD_WS_STRICTER_RFC6455` validates the UTF-8 of a complete, unfragmented TEXT frame only. It does not validate a TEXT message that arrives in fragments. To enforce `RFC 6455, section 8.1 `_ on such a message, join the fragments and call :cpp:func:`httpd_ws_validate_utf8` on the result. + +On transmit, the server does not fragment a message automatically. To send fragments, set the ``fragmented`` option and mark the last fragment with the ``final`` option. + + WebSocket Control Frame Handler ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/protocols/http_server/ws_echo_server/README.md b/examples/protocols/http_server/ws_echo_server/README.md index 201acc52c4d..72eab3629df 100644 --- a/examples/protocols/http_server/ws_echo_server/README.md +++ b/examples/protocols/http_server/ws_echo_server/README.md @@ -69,9 +69,21 @@ httpd_ws_recv_frame(req, &ws_pkt, MAX_PAYLOAD_LEN); 2) Allocate the size based on the received packet length 3) Call `httpd_ws_recv_frame()` with the allocated buffer +#### Fragmented messages + +The WebSocket HTTP server does not support fragmented messages, as [RFC6455, section 5.4](https://tools.ietf.org/html/rfc6455#section-5.4) defines them. + +On receive, the server passes each frame to the handler on its own. It does not join the fragments of one message. A handler that gets a message in three fragments sees three separate frames. Use the `final` and `fragmented` fields of `httpd_ws_frame_t` to detect a fragment, and join the payloads in the application. + +The server also does not validate the fragment sequence. It accepts a CONTINUE frame that continues no message. It also accepts a new TEXT or BINARY frame while a fragmented message is still open. RFC 6455 requires a close with status code 1002 in both cases. + +`CONFIG_HTTPD_WS_STRICTER_RFC6455` validates the UTF-8 of a complete, unfragmented TEXT frame only. It does not validate a TEXT message that arrives in fragments. Join the fragments and call `httpd_ws_validate_utf8()` on the result. + +This example echoes each frame as a complete message, so it does not handle fragments either. + #### Handling outgoing data -Please note that the WebSocket HTTP server does not automatically fragment messages. +On transmit, the server does not automatically fragment messages. Each outgoing frame has the FIN flag set by default. In case an application wants to send fragmented data, it must be done manually by setting the `fragmented` option and using the `final` flag as described in [RFC6455, section 5.4](https://tools.ietf.org/html/rfc6455#section-5.4). diff --git a/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c b/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c index c83e142b93a..0d2a67d3a4a 100644 --- a/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c +++ b/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c @@ -215,9 +215,11 @@ static esp_err_t echo_handler(httpd_req_t *req) } } - ret = httpd_ws_send_frame(req, &ws_pkt); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "httpd_ws_send_frame failed with %d", ret); + if (ws_pkt.type == HTTPD_WS_TYPE_TEXT || ws_pkt.type == HTTPD_WS_TYPE_BINARY) { + ret = httpd_ws_send_frame(req, &ws_pkt); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "httpd_ws_send_frame failed with %d", ret); + } } free(buf); return ret;