diff --git a/components/esp_http_server/Kconfig b/components/esp_http_server/Kconfig index c7142e782fe..8559302ca6f 100644 --- a/components/esp_http_server/Kconfig +++ b/components/esp_http_server/Kconfig @@ -50,6 +50,26 @@ menu "HTTP Server" help This sets the WebSocket server support. + config HTTPD_WS_STRICTER_RFC6455 + bool "Enforce stricter RFC 6455 WebSocket conformance" + default n + depends on HTTPD_WS_SUPPORT + help + Enable stricter RFC 6455 enforcement in the WebSocket server. When enabled, + the server validates handshake headers (Host, Sec-WebSocket-Version, + 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. + + This option defaults to off in this release cycle so existing deployments + see no behavior change. Enable to opt into stricter enforcement; lenient + mode (the default) silently accepts protocol violations that may produce + security or interoperability issues, so enable as soon as your clients are + verified conformant. + config HTTPD_QUEUE_WORK_BLOCKING bool "httpd_queue_work as blocking API" help diff --git a/components/esp_http_server/src/esp_httpd_priv.h b/components/esp_http_server/src/esp_httpd_priv.h index 6eea19c9820..e0fd7492e45 100644 --- a/components/esp_http_server/src/esp_httpd_priv.h +++ b/components/esp_http_server/src/esp_httpd_priv.h @@ -520,17 +520,23 @@ int httpd_default_recv(httpd_handle_t hd, int sockfd, char *buf, size_t buf_len, /** - * @brief This function is for responding a WebSocket handshake + * @brief Respond to a WebSocket opening handshake. + * + * On any RFC 6455 §4.2.1 handshake validation failure (missing Host, + * malformed Sec-WebSocket-Key, unsupported Sec-WebSocket-Version, etc.), + * this function sends the appropriate HTTP error response (400 Bad Request + * or 426 Upgrade Required) to the client itself and returns ESP_FAIL. + * Callers MUST NOT attempt to send another response on the ESP_FAIL path. * * @param[in] req Pointer to handshake request that will be handled * @param[in] supported_subprotocol Pointer to the subprotocol supported by this URI * @return - * - ESP_OK : When handshake is successful - * - ESP_ERR_NOT_FOUND : When some headers (Sec-WebSocket-*) are not found - * - ESP_ERR_INVALID_VERSION : The WebSocket version is not "13" - * - ESP_ERR_INVALID_STATE : Handshake was done beforehand - * - ESP_ERR_INVALID_ARG : Argument is invalid (null or non-WebSocket) - * - ESP_FAIL : Socket failures + * - ESP_OK : Handshake successful; 101 Switching Protocols sent + * - ESP_ERR_INVALID_ARG : @p req or its aux pointer is NULL + * - ESP_ERR_INVALID_STATE : Handshake was already performed on this session + * - ESP_FAIL : Handshake-validation failure (HTTP error already sent), + * memory allocation failure, hash/encode failure, + * or socket send failure */ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *supported_subprotocol); diff --git a/components/esp_http_server/src/httpd_parse.c b/components/esp_http_server/src/httpd_parse.c index d097db5f197..7902bdc7681 100644 --- a/components/esp_http_server/src/httpd_parse.c +++ b/components/esp_http_server/src/httpd_parse.c @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2018-2025 Espressif Systems (Shanghai) CO LTD + * SPDX-FileCopyrightText: 2018-2026 Espressif Systems (Shanghai) CO LTD * * SPDX-License-Identifier: Apache-2.0 */ @@ -86,7 +86,11 @@ static esp_err_t verify_url (http_parser *parser) ((char *)r->uri)[length] = '\0'; ESP_LOGD(TAG, LOG_FMT("received URI = %s"), r->uri); - /* Make sure version is HTTP/1.1 or HTTP/1.0 (legacy compliance purpose) */ + /* Make sure version is HTTP/1.1 or HTTP/1.0 (legacy compliance purpose). + * The stricter HTTP/1.1 requirement for WebSocket handshakes is enforced + * later in cb_headers_complete(), once the Upgrade header confirms that + * the request is actually a WS handshake. Enforcing it here would also + * reject HTTP/1.0 clients on regular (non-WS) endpoints. */ if (!((parser->http_major == 1) && ((parser->http_minor == 0) || (parser->http_minor == 1)))) { ESP_LOGW(TAG, LOG_FMT("unsupported HTTP version = %d.%d"), parser->http_major, parser->http_minor); @@ -396,6 +400,20 @@ static esp_err_t cb_headers_complete(http_parser *parser) return ESP_FAIL; } +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* RFC 6455 §4.2.1: the WebSocket opening handshake MUST use HTTP/1.1. + * This check is intentionally scoped to confirmed WS handshakes so + * regular HTTP/1.0 traffic on non-WS endpoints keeps working. */ + if (!(parser->http_major == 1 && parser->http_minor == 1)) { + ESP_LOGW(TAG, LOG_FMT("WebSocket handshake requires HTTP/1.1, got %d.%d"), + parser->http_major, parser->http_minor); + + parser_data->error = HTTPD_400_BAD_REQUEST; + parser_data->status = PARSING_FAILED; + return ESP_FAIL; + } +#endif + /* Now set handshake flag to true */ ra->ws_handshake_detect = true; #else diff --git a/components/esp_http_server/src/httpd_ws.c b/components/esp_http_server/src/httpd_ws.c index f479a8b0a88..13cc94c464e 100644 --- a/components/esp_http_server/src/httpd_ws.c +++ b/components/esp_http_server/src/httpd_ws.c @@ -22,8 +22,12 @@ #ifdef CONFIG_HTTPD_WS_SUPPORT -#define WS_SEND_OK (1 << 0) -#define WS_SEND_FAILED (1 << 1) +#define WS_SEND_OK (1 << 0) +#define WS_SEND_FAILED (1 << 1) + +#define SEC_WEBSOCKET_VERSION_HDR_MAX_LEN 255 +#define SEC_WEBSOCKET_KEY_HDR_MAX_LEN 255 +#define SEC_WEBSOCKET_VERSION "13" typedef struct { httpd_ws_frame_t frame; @@ -99,6 +103,23 @@ static bool httpd_ws_get_response_subprotocol(const char *supported_subprotocol, } +/* Send an HTTP error response for a rejected WS handshake. + * Optionally adds a Sec-WebSocket-Version header (RFC 6455 §4.4). + * Returns ESP_FAIL so callers can write: return httpd_ws_send_handshake_error(...). + */ +static esp_err_t httpd_ws_send_handshake_error(httpd_req_t *req, const char *status, + const char *message, const char *supported_version) +{ + if (supported_version != NULL) { + if (httpd_resp_set_hdr(req, "Sec-WebSocket-Version", supported_version) != ESP_OK) { + ESP_LOGE(TAG, LOG_FMT("Failed to set Sec-WebSocket-Version header")); + return ESP_FAIL; + } + } + esp_err_t ret = httpd_resp_send_custom_err(req, status, message); + return (ret == ESP_OK) ? ESP_FAIL : ret; +} + esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *supported_subprotocol) { /* Probe if input parameters are valid or not */ @@ -114,32 +135,96 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor return ESP_ERR_INVALID_STATE; } - /* Detect WS version existence */ - char version_val[3] = { '\0' }; - if (httpd_req_get_hdr_value_str(req, "Sec-WebSocket-Version", version_val, sizeof(version_val)) != ESP_OK) { +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + /* RFC 6455 §4.2.1: Host header MUST be present */ + size_t host_hdr_len = httpd_req_get_hdr_value_len(req, "Host"); + if (host_hdr_len == 0) { + ESP_LOGW(TAG, LOG_FMT("\"Host\" is not found")); + return httpd_ws_send_handshake_error(req, "400 Bad Request", "Missing Host header", NULL); + } +#endif + + /* RFC 6455 §4.2.1: Sec-WebSocket-Version must be present and equal "13" */ + size_t version_hdr_len = httpd_req_get_hdr_value_len(req, "Sec-WebSocket-Version"); + if (version_hdr_len == 0) { ESP_LOGW(TAG, LOG_FMT("\"Sec-WebSocket-Version\" is not found")); - return ESP_ERR_NOT_FOUND; + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Missing Sec-WebSocket-Version header", NULL); } - - /* Detect if WS version is "13" or not. - * WS version must be 13 for now. Please refer to RFC6455 Section 4.1, Page 18 for more details. */ - if (strcasecmp(version_val, "13") != 0) { +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + if (version_hdr_len > SEC_WEBSOCKET_VERSION_HDR_MAX_LEN) { + ESP_LOGW(TAG, LOG_FMT("\"Sec-WebSocket-Version\" is too long")); + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Invalid Sec-WebSocket-Version header", NULL); + } +#endif + char *version_val = calloc(1, version_hdr_len + 1); + if (version_val == NULL) { + ESP_LOGE(TAG, "Failed to allocate version header buffer"); + return ESP_FAIL; + } + if (httpd_req_get_hdr_value_str(req, "Sec-WebSocket-Version", version_val, version_hdr_len + 1) != ESP_OK) { + free(version_val); + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Invalid Sec-WebSocket-Version header", NULL); + } + /* WS version must be 13. Please refer to RFC6455 Section 4.1, Page 18 for more details. */ + if (strcasecmp(version_val, SEC_WEBSOCKET_VERSION) != 0) { ESP_LOGW(TAG, LOG_FMT("\"Sec-WebSocket-Version\" is not \"13\", it is: %s"), version_val); - return ESP_ERR_INVALID_VERSION; + free(version_val); + return httpd_ws_send_handshake_error(req, "426 Upgrade Required", + "WebSocket version not supported", "13"); } + free(version_val); - /* Grab Sec-WebSocket-Key (client key) from the header */ - /* Size of base64 coded string is equal '((input_size * 4) / 3) + (input_size / 96) + 6' including Z-term */ - char sec_key_encoded[28] = { '\0' }; - if (httpd_req_get_hdr_value_str(req, "Sec-WebSocket-Key", sec_key_encoded, sizeof(sec_key_encoded)) != ESP_OK) { + /* RFC 6455 §4.2.1 / §4.3: Sec-WebSocket-Key must be present (strict mode + * additionally validates that it base64-decodes to a 16-byte nonce). */ + size_t sec_key_hdr_len = httpd_req_get_hdr_value_len(req, "Sec-WebSocket-Key"); + if (sec_key_hdr_len == 0) { ESP_LOGW(TAG, LOG_FMT("Cannot find client key")); - return ESP_ERR_NOT_FOUND; + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Missing Sec-WebSocket-Key header", NULL); } +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + if (sec_key_hdr_len > SEC_WEBSOCKET_KEY_HDR_MAX_LEN) { + ESP_LOGW(TAG, LOG_FMT("Sec-WebSocket-Key header is too long")); + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Invalid Sec-WebSocket-Key header", NULL); + } +#endif + char *sec_key_encoded = calloc(1, sec_key_hdr_len + 1); + if (sec_key_encoded == NULL) { + ESP_LOGE(TAG, "Failed to allocate Sec-WebSocket-Key buffer"); + return ESP_FAIL; + } + if (httpd_req_get_hdr_value_str(req, "Sec-WebSocket-Key", sec_key_encoded, sec_key_hdr_len + 1) != ESP_OK) { + free(sec_key_encoded); + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Invalid Sec-WebSocket-Key header", NULL); + } +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 + uint8_t decoded_key[16] = { 0 }; + size_t decoded_key_len = 0; + if (mbedtls_base64_decode(decoded_key, sizeof(decoded_key), &decoded_key_len, + (const unsigned char *)sec_key_encoded, strlen(sec_key_encoded)) != 0 || + decoded_key_len != sizeof(decoded_key)) { + free(sec_key_encoded); + ESP_LOGW(TAG, LOG_FMT("Sec-WebSocket-Key is not a valid base64-encoded 16-byte nonce")); + return httpd_ws_send_handshake_error(req, "400 Bad Request", + "Invalid Sec-WebSocket-Key header", NULL); + } +#endif /* Prepare server key (Sec-WebSocket-Accept), concat the string */ char server_key_encoded[33] = { '\0' }; uint8_t server_key_hash[20] = { 0 }; - char server_raw_text[sizeof(sec_key_encoded) + sizeof(ws_magic_uuid) + 1] = { '\0' }; + size_t server_raw_text_len = strlen(sec_key_encoded) + strlen(ws_magic_uuid) + 1; + char *server_raw_text = calloc(1, server_raw_text_len); + if (server_raw_text == NULL) { + free(sec_key_encoded); + ESP_LOGE(TAG, "Failed to allocate handshake hash input buffer"); + return ESP_FAIL; + } strcpy(server_raw_text, sec_key_encoded); strcat(server_raw_text, ws_magic_uuid); @@ -150,12 +235,16 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor psa_hash_operation_t sha1_operation = PSA_HASH_OPERATION_INIT; psa_status_t status = psa_hash_setup(&sha1_operation, PSA_ALG_SHA_1); if (status != PSA_SUCCESS) { + free(server_raw_text); + free(sec_key_encoded); ESP_LOGE(TAG, "Failed to setup SHA-1 operation"); return ESP_FAIL; } status = psa_hash_update(&sha1_operation, (uint8_t *)server_raw_text, strlen(server_raw_text)); if (status != PSA_SUCCESS) { + free(server_raw_text); + free(sec_key_encoded); ESP_LOGE(TAG, "Failed to update SHA-1 hash"); psa_hash_abort(&sha1_operation); return ESP_FAIL; @@ -163,7 +252,9 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor size_t hash_length; status = psa_hash_finish(&sha1_operation, server_key_hash, sizeof(server_key_hash), &hash_length); + free(server_raw_text); if (status != PSA_SUCCESS || hash_length != sizeof(server_key_hash)) { + free(sec_key_encoded); ESP_LOGE(TAG, "Failed to finish SHA-1 hash"); return ESP_FAIL; } @@ -173,6 +264,8 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor mbedtls_base64_encode((uint8_t *)server_key_encoded, sizeof(server_key_encoded), &encoded_len, server_key_hash, sizeof(server_key_hash)); + free(sec_key_encoded); + ESP_LOGD(TAG, LOG_FMT("Generated server key: %s"), server_key_encoded); char subprotocol[50] = { '\0' }; diff --git a/components/esp_http_server/test_apps/main/CMakeLists.txt b/components/esp_http_server/test_apps/main/CMakeLists.txt index a97994f3498..81af38a9c73 100644 --- a/components/esp_http_server/test_apps/main/CMakeLists.txt +++ b/components/esp_http_server/test_apps/main/CMakeLists.txt @@ -1,3 +1,3 @@ -idf_component_register(SRC_DIRS "." - PRIV_INCLUDE_DIRS "." "../../src" "../../src/port/esp32" +idf_component_register(SRC_DIRS "." "../mock_client" + PRIV_INCLUDE_DIRS "." "../../src" "../../src/port/esp32" "../mock_client" PRIV_REQUIRES esp_http_server test_utils unity esp_timer) 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 22fb55d7b88..fbf8fcbccc0 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 @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include "unity.h" #include "test_utils.h" +#include "mock_http_server_client.h" int pre_start_mem, post_stop_mem, post_stop_min_mem; bool basic_sanity = true; @@ -31,6 +33,30 @@ esp_err_t null_func(httpd_req_t *req) return ESP_OK; } +#ifdef CONFIG_HTTPD_WS_SUPPORT +static httpd_handle_t start_test_ws_server(uint16_t server_port, uint16_t ctrl_port) +{ + httpd_handle_t hd = NULL; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = server_port; + config.ctrl_port = ctrl_port; + + httpd_uri_t ws_uri = { + .uri = "/ws", + .method = HTTP_GET, + .handler = null_func, + .user_ctx = NULL, + .is_websocket = true, + .handle_ws_control_frames = false, + .supported_subprotocol = NULL, + }; + + TEST_ASSERT_EQUAL(ESP_OK, httpd_start(&hd, &config)); + 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 = { @@ -519,6 +545,129 @@ 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. */ +TEST_CASE("Non-WS HTTP/1.0 request is not rejected by WS version check", "[HTTP SERVER][websocket]") +{ + test_case_uses_tcpip(); + httpd_handle_t hd = start_test_ws_server(8097, ESP_HTTPD_DEF_CTRL_PORT + 16); + mock_server_request_t req = { + .data = "GET /non-ws HTTP/1.0\r\n" + "Host: localhost\r\n\r\n", + }; + mock_server_response_t *resp = mock_server_send_request(8097, &req); + TEST_ASSERT_NOT_NULL(resp); + /* No handler is registered for /non-ws, so the server returns 404. + * The crucial assertion is that it is NOT 400 from the WS version check — + * i.e., HTTP/1.0 was accepted at the parser level. */ + mock_server_assert_status(resp, 404); + mock_server_response_free(resp); + 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(); + httpd_handle_t hd = start_test_ws_server(8094, ESP_HTTPD_DEF_CTRL_PORT + 13); + mock_server_request_t req = { + .data = "GET /ws HTTP/1.1\r\n" + "Host: localhost\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + }; + mock_server_response_t *resp = mock_server_send_request(8094, &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 unsupported version returns 426 with Sec-WebSocket-Version header", "[HTTP SERVER][websocket]") +{ + test_case_uses_tcpip(); + httpd_handle_t hd = start_test_ws_server(8093, ESP_HTTPD_DEF_CTRL_PORT + 12); + mock_server_request_t req = { + .data = "GET /ws HTTP/1.1\r\n" + "Host: localhost\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 12\r\n\r\n", + }; + mock_server_response_t *resp = mock_server_send_request(8093, &req); + TEST_ASSERT_NOT_NULL(resp); + mock_server_assert_status(resp, 426); + TEST_ASSERT_NOT_NULL(strstr(resp->data, "Sec-WebSocket-Version: 13")); + mock_server_response_free(resp); + TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd)); +} + +#if CONFIG_HTTPD_WS_STRICTER_RFC6455 +TEST_CASE("WS handshake invalid Sec-WebSocket-Key returns 400", "[HTTP SERVER][websocket]") +{ + test_case_uses_tcpip(); + httpd_handle_t hd = start_test_ws_server(8092, ESP_HTTPD_DEF_CTRL_PORT + 11); + mock_server_request_t req = { + .data = "GET /ws HTTP/1.1\r\n" + "Host: localhost\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: AQID\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n", + }; + mock_server_response_t *resp = mock_server_send_request(8092, &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 */ + #endif /* CONFIG_HTTPD_WS_SUPPORT */ void app_main(void) diff --git a/components/esp_http_server/test_apps/mock_client/mock_http_server_client.c b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.c new file mode 100644 index 00000000000..d97f9c8bf80 --- /dev/null +++ b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.c @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "unity.h" + +#include "mock_http_server_client.h" + +static const char *TAG = "mock_srv_client"; + +/* ----------------------------------------------------------------------- + * Internal helpers + * --------------------------------------------------------------------- */ + +/** + * Parse the HTTP status code from the beginning of a raw response buffer. + * Expects "HTTP/1.x NNN ..." format. Returns -1 on any parse failure. + */ +static int parse_status_code(const char *data, size_t len) +{ + /* Minimum: "HTTP/1.1 200 " = 13 chars */ + if (len < 13) { + return -1; + } + /* Find the first space (end of "HTTP/1.x") */ + const char *p = memchr(data, ' ', len < 16 ? len : 16); + if (!p) { + return -1; + } + p++; /* point at status code digits */ + if ((size_t)(p - data) + 3 > len) { + return -1; + } + int code = 0; + for (int i = 0; i < 3; i++) { + if (p[i] < '0' || p[i] > '9') { + return -1; + } + code = code * 10 + (p[i] - '0'); + } + /* Sanity: valid HTTP status codes are 100–599 */ + if (code < 100 || code > 599) { + return -1; + } + return code; +} + +/* ----------------------------------------------------------------------- + * Public API + * --------------------------------------------------------------------- */ + +mock_server_response_t *mock_server_send_request(uint16_t port, + const mock_server_request_t *req) +{ + mock_server_response_t *resp = calloc(1, sizeof(*resp)); + if (!resp) { + ESP_LOGE(TAG, "calloc() failed"); + return NULL; + } + resp->status_code = -1; + + /* ---- Create socket and connect ---- */ + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + ESP_LOGE(TAG, "socket() failed: errno=%d", errno); + free(resp); + return NULL; + } + + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + ESP_LOGE(TAG, "connect() to 127.0.0.1:%u failed: errno=%d", port, errno); + close(fd); + free(resp); + return NULL; + } + ESP_LOGD(TAG, "connected to 127.0.0.1:%u", port); + + /* ---- Send the scripted request ---- */ + size_t total_len = req->len ? req->len : strlen(req->data); + size_t sent = 0; + + /* How many bytes to send in each write() call */ + int max_per_write = (req->max_bytes_per_write > 0) + ? req->max_bytes_per_write + : (int)total_len; + + /* After how many sent bytes to stop writing. + * 0 or negative = send everything (zero is the zero-init default for + * unset struct fields, so it must mean "no early close"). */ + int close_after = req->close_after_bytes; + + while (sent < total_len) { + /* Honour early-close limit */ + if (close_after > 0 && (int)sent >= close_after) { + break; + } + + int chunk = max_per_write; + /* Don't exceed the early-close byte count */ + if (close_after > 0 && (int)(sent + chunk) > close_after) { + chunk = close_after - (int)sent; + } + /* Don't exceed total length */ + if ((size_t)chunk > total_len - sent) { + chunk = (int)(total_len - sent); + } + + int n = send(fd, req->data + sent, (size_t)chunk, 0); + if (n <= 0) { + ESP_LOGE(TAG, "send() failed after %zu bytes: errno=%d", sent, errno); + close(fd); + free(resp); + return NULL; + } + sent += (size_t)n; + ESP_LOGD(TAG, "sent %d bytes (%zu/%zu total)", n, sent, total_len); + + if (req->write_delay_ms > 0 && sent < total_len) { + vTaskDelay(pdMS_TO_TICKS(req->write_delay_ms)); + } + } + + /* Signal end-of-request to the server if we stopped sending early */ + if (close_after > 0) { + shutdown(fd, SHUT_WR); + ESP_LOGD(TAG, "shutdown(SHUT_WR) after %zu bytes", sent); + } + + /* ---- Read the response ---- */ + int timeout_ms = (req->recv_timeout_ms > 0) ? req->recv_timeout_ms : 2000; + + /* Record absolute deadline so that EINTR retries don't reset the clock. */ + struct timeval deadline; + gettimeofday(&deadline, NULL); + deadline.tv_sec += timeout_ms / 1000; + deadline.tv_usec += (timeout_ms % 1000) * 1000; + if (deadline.tv_usec >= 1000000) { + deadline.tv_sec++; + deadline.tv_usec -= 1000000; + } + + size_t received = 0; + while (received < sizeof(resp->data) - 1) { + /* Calculate remaining time until deadline. */ + struct timeval now, remaining; + gettimeofday(&now, NULL); + remaining.tv_sec = deadline.tv_sec - now.tv_sec; + remaining.tv_usec = deadline.tv_usec - now.tv_usec; + if (remaining.tv_usec < 0) { + remaining.tv_sec--; + remaining.tv_usec += 1000000; + } + if (remaining.tv_sec < 0) { + ESP_LOGD(TAG, "recv deadline elapsed after %zu bytes", received); + break; + } + + /* Wait for data (or deadline) — handles EINTR safely. */ + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(fd, &read_fds); + int ready = select(fd + 1, &read_fds, NULL, NULL, &remaining); + if (ready < 0) { + if (errno == EINTR) { + continue; /* signal interrupted select — recalculate remaining time */ + } + ESP_LOGE(TAG, "select() error: errno=%d", errno); + break; + } + if (ready == 0) { + ESP_LOGD(TAG, "recv deadline elapsed after %zu bytes", received); + break; + } + + int n = recv(fd, resp->data + received, sizeof(resp->data) - 1 - received, 0); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { + continue; + } + ESP_LOGE(TAG, "recv() error after %zu bytes: errno=%d", received, errno); + break; + } + if (n == 0) { + resp->server_closed = true; + ESP_LOGD(TAG, "server closed connection after %zu bytes", received); + break; + } + received += (size_t)n; + } + + resp->len = received; + resp->data[received] = '\0'; /* NUL-terminate for easy string inspection */ + + if (received > 0) { + resp->status_code = parse_status_code(resp->data, received); + ESP_LOGD(TAG, "response: status=%d, len=%zu", resp->status_code, received); + } else { + ESP_LOGW(TAG, "no response received from server"); + } + + close(fd); + return resp; +} + +void mock_server_response_free(mock_server_response_t *resp) +{ + free(resp); +} + +void mock_server_assert_status(const mock_server_response_t *resp, int expected_status) +{ + if (resp->len == 0) { + TEST_FAIL_MESSAGE("mock_server: no response received from server"); + return; + } + if (resp->status_code == -1) { + /* Print the raw response to help diagnose the issue */ + ESP_LOGE(TAG, "unparsable response (%zu bytes): %.80s", resp->len, resp->data); + TEST_FAIL_MESSAGE("mock_server: server response is not a valid HTTP status line"); + return; + } + TEST_ASSERT_EQUAL_MESSAGE(expected_status, resp->status_code, + "HTTP status code mismatch"); +} diff --git a/components/esp_http_server/test_apps/mock_client/mock_http_server_client.h b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.h new file mode 100644 index 00000000000..80b27413ddc --- /dev/null +++ b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.h @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file mock_http_server_client.h + * @brief Scripted TCP client for black-box testing of esp_http_server. + * + * Allows tests to send arbitrary (including malformed or fragmented) byte + * sequences to a running httpd instance over loopback and capture the raw + * response. The server under test is started/stopped by the test itself using + * the normal httpd_start() / httpd_stop() APIs; this module only provides the + * "client" side. + */ + +#pragma once + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Maximum number of response bytes the mock client will buffer. */ +#define MOCK_SERVER_CLIENT_RESP_BUF_SIZE 4096 + +/** + * @brief Describes a single scripted request the mock client sends. + * + * Only @p data is required. All other fields have safe defaults when zero. + */ +typedef struct { + const char *data; /**< Raw bytes to send (must not be NULL) */ + size_t len; /**< Length of @p data; 0 → strlen(data) */ + + /** Maximum bytes per write() call. -1 (or 0) = send all at once. + * Set to a small value to simulate a slow/trickled sender. */ + int max_bytes_per_write; + + /** Delay in milliseconds between fragmented writes. 0 = no delay. */ + int write_delay_ms; + + /** Close the TCP write-side after sending this many bytes. + * 0 (default) or negative = send everything, keep write-side open. + * Positive N = close after N bytes (useful for partial-request / abrupt-close tests). */ + int close_after_bytes; + + /** How long (ms) to wait for a response before giving up. 0 → 2000 ms. */ + int recv_timeout_ms; +} mock_server_request_t; + +/** + * @brief Result captured from the server after sending a scripted request. + */ +typedef struct { + char data[MOCK_SERVER_CLIENT_RESP_BUF_SIZE]; /**< Raw response bytes */ + size_t len; /**< Bytes actually received */ + int status_code; /**< HTTP status code parsed from the status line, + * or -1 if the response is not a valid HTTP response. */ + bool server_closed; /**< True if the server closed the connection (recv returned 0). */ +} mock_server_response_t; + +/** + * @brief Connect to a running httpd on 127.0.0.1:@p port, send the scripted + * request and capture the response. + * + * The response struct is heap-allocated; the caller must free it with + * mock_server_response_free() when done. The TCP connection is always + * closed before returning. + * + * @param[in] port TCP port the httpd server is listening on. + * @param[in] req Scripted request to send. + * + * @return Heap-allocated response, or NULL if a TCP-level error occurred. + */ +mock_server_response_t *mock_server_send_request(uint16_t port, + const mock_server_request_t *req); + +/** + * @brief Free a response returned by mock_server_send_request(). + */ +void mock_server_response_free(mock_server_response_t *resp); + +/** + * @brief Assert that @p resp contains a valid HTTP response with the expected + * status code. Calls TEST_FAIL() (Unity) if the assertion does not hold. + */ +void mock_server_assert_status(const mock_server_response_t *resp, int expected_status); + +#ifdef __cplusplus +} +#endif diff --git a/components/esp_http_server/test_apps/sdkconfig.defaults b/components/esp_http_server/test_apps/sdkconfig.defaults index e215671eec0..1cc355e3a79 100644 --- a/components/esp_http_server/test_apps/sdkconfig.defaults +++ b/components/esp_http_server/test_apps/sdkconfig.defaults @@ -8,3 +8,4 @@ CONFIG_COMPILER_STACK_CHECK=y CONFIG_ESP_TASK_WDT_EN=n CONFIG_HTTPD_WS_SUPPORT=y +CONFIG_HTTPD_WS_STRICTER_RFC6455=y