fix(ws): enforce RFC 6455 §4.2.1 handshake requirements

This commit is contained in:
Ashish Sharma
2026-05-25 15:04:10 +08:00
parent 64287ae681
commit 7d53b4740b
7 changed files with 286 additions and 46 deletions

View File

@@ -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

View File

@@ -533,17 +533,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);

View File

@@ -87,7 +87,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);
@@ -412,6 +416,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

View File

@@ -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' };

View File

@@ -471,6 +471,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 */
/********* URL query / header pointer-accessor tests *********

View File

@@ -13,27 +13,6 @@
* 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.
*
* Typical usage:
* @code
* // 1. Start the server
* httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
* cfg.server_port = 8099;
* httpd_handle_t server;
* httpd_start(&server, &cfg);
* httpd_register_uri_handler(server, &my_handler);
*
* // 2. Send a scripted request and capture the response
* mock_server_request_t req = {
* .data = "GET /path HTTP/1.1\r\nHost: localhost\r\n\r\n",
* };
* mock_server_response_t resp = {0};
* mock_server_send_request(8099, &req, &resp);
* TEST_ASSERT_EQUAL(200, resp.status_code);
*
* // 3. Stop the server
* httpd_stop(server);
* @endcode
*/
#pragma once

View File

@@ -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