From 6b270388fbfea73b7727499d8cad3041b26ba6fb Mon Sep 17 00:00:00 2001 From: Distortions81 Date: Tue, 4 Aug 2026 18:53:37 -0600 Subject: [PATCH 1/4] test: add websocket validation helpers --- components/api_rx/CMakeLists.txt | 4 +++ components/api_rx/api_rx.c | 36 +++++++++++++++++++++++++ components/api_rx/include/api_rx.h | 12 +++++++++ components/api_rx/test/CMakeLists.txt | 5 ++++ components/api_rx/test/test_api_rx.c | 39 +++++++++++++++++++++++++++ main/CMakeLists.txt | 1 + test/CMakeLists.txt | 2 +- 7 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 components/api_rx/CMakeLists.txt create mode 100644 components/api_rx/api_rx.c create mode 100644 components/api_rx/include/api_rx.h create mode 100644 components/api_rx/test/CMakeLists.txt create mode 100644 components/api_rx/test/test_api_rx.c diff --git a/components/api_rx/CMakeLists.txt b/components/api_rx/CMakeLists.txt new file mode 100644 index 0000000000..f9d5d6eb6b --- /dev/null +++ b/components/api_rx/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "api_rx.c" + INCLUDE_DIRS "include" +) diff --git a/components/api_rx/api_rx.c b/components/api_rx/api_rx.c new file mode 100644 index 0000000000..388e367792 --- /dev/null +++ b/components/api_rx/api_rx.c @@ -0,0 +1,36 @@ +#include +#include + +#include "api_rx.h" + +bool api_rx_websocket_payload_fits(size_t payload_len) +{ + return payload_len <= API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE; +} + +bool api_rx_websocket_origin_matches_host(const char *origin, const char *host) +{ + if (origin == NULL || host == NULL || host[0] == '\0') { + return false; + } + + const char *authority = NULL; + static const char http_prefix[] = "http://"; + static const char https_prefix[] = "https://"; + + if (strncasecmp(origin, http_prefix, sizeof(http_prefix) - 1) == 0) { + authority = origin + sizeof(http_prefix) - 1; + } else if (strncasecmp(origin, https_prefix, sizeof(https_prefix) - 1) == 0) { + authority = origin + sizeof(https_prefix) - 1; + } else { + return false; + } + + size_t authority_len = strcspn(authority, "/?#"); + if (authority_len == 0 || authority[authority_len] != '\0') { + return false; + } + + size_t host_len = strlen(host); + return authority_len == host_len && strncasecmp(authority, host, host_len) == 0; +} diff --git a/components/api_rx/include/api_rx.h b/components/api_rx/include/api_rx.h new file mode 100644 index 0000000000..2f00722ffc --- /dev/null +++ b/components/api_rx/include/api_rx.h @@ -0,0 +1,12 @@ +#ifndef API_RX_H_ +#define API_RX_H_ + +#include +#include + +#define API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE 1024U + +bool api_rx_websocket_payload_fits(size_t payload_len); +bool api_rx_websocket_origin_matches_host(const char *origin, const char *host); + +#endif /* API_RX_H_ */ diff --git a/components/api_rx/test/CMakeLists.txt b/components/api_rx/test/CMakeLists.txt new file mode 100644 index 0000000000..62886cf2a3 --- /dev/null +++ b/components/api_rx/test/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "test_api_rx.c" + INCLUDE_DIRS "." + REQUIRES unity api_rx +) diff --git a/components/api_rx/test/test_api_rx.c b/components/api_rx/test/test_api_rx.c new file mode 100644 index 0000000000..8f12053cfd --- /dev/null +++ b/components/api_rx/test/test_api_rx.c @@ -0,0 +1,39 @@ +#include + +#include "api_rx.h" +#include "unity.h" + +TEST_CASE("WebSocket payload limit has strict boundary", "[api_rx]") +{ + TEST_ASSERT_TRUE(api_rx_websocket_payload_fits(0)); + TEST_ASSERT_TRUE(api_rx_websocket_payload_fits(API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE)); + TEST_ASSERT_FALSE(api_rx_websocket_payload_fits(API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE + 1U)); + TEST_ASSERT_FALSE(api_rx_websocket_payload_fits(SIZE_MAX)); +} + +TEST_CASE("WebSocket origin must match request host", "[api_rx]") +{ + TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( + "http://192.168.1.42", "192.168.1.42")); + TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( + "http://bitaxe.local", "bitaxe.local")); + TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( + "https://BITAXE.local", "bitaxe.LOCAL")); + TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( + "http://bitaxe.local:8080", "bitaxe.local:8080")); + + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( + "http://evil.local", "bitaxe.local")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( + "http://bitaxe.local:8080", "bitaxe.local")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( + "null", "bitaxe.local")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( + "file://bitaxe.local", "bitaxe.local")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( + "http://bitaxe.local/", "bitaxe.local")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( + "http://bitaxe.local@example.com", "example.com")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host(NULL, "bitaxe.local")); + TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host("http://bitaxe.local", NULL)); +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 917dd9274d..5a3a3b0727 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -90,6 +90,7 @@ PRIV_REQUIRES "esp_driver_i2c" "esp_driver_uart" "tcp_transport" + "api_rx" "esp_mm" "bt" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e98ced1503..d972cfb1f3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,7 +10,7 @@ set(EXTRA_COMPONENT_DIRS "../components") # - when invoking CMake directly: cmake -D TEST_COMPONENTS="xxxxx" .. # - when using idf.py: idf.py -T xxxxx build # -set(TEST_COMPONENTS "stratum asic" CACHE STRING "List of components to test") +set(TEST_COMPONENTS "stratum asic api_rx" CACHE STRING "List of components to test") include($ENV{IDF_PATH}/tools/cmake/project.cmake) From a227d9f699218a6559f3bfbdd28bf665cbd64998 Mon Sep 17 00:00:00 2001 From: Distortions81 Date: Tue, 4 Aug 2026 18:54:00 -0600 Subject: [PATCH 2/4] fix: bound inbound websocket frames --- main/http_server/websocket.c | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/main/http_server/websocket.c b/main/http_server/websocket.c index cde0298a50..8e0c23ff03 100644 --- a/main/http_server/websocket.c +++ b/main/http_server/websocket.c @@ -10,6 +10,7 @@ #include "websocket_api.h" #include "http_server.h" #include "log_buffer.h" +#include "api_rx.h" #define WS_LOG_SCRATCH_SIZE 2048 @@ -221,15 +222,18 @@ esp_err_t websocket_handler(httpd_req_t *req) return ret; } - // If there's a payload, drain it + // Inbound application data is ignored, but it must be drained to keep the + // WebSocket stream synchronized. Never allocate based on a peer-provided + // frame length. if (ws_pkt.len > 0) { - uint8_t *buf = (uint8_t *)calloc(1, ws_pkt.len + 1); - if (buf) { - ws_pkt.payload = buf; - ret = httpd_ws_recv_frame(req, &ws_pkt, ws_pkt.len); - free(buf); - return ret; + if (!api_rx_websocket_payload_fits(ws_pkt.len)) { + ESP_LOGW(TAG, "Rejecting oversized WebSocket frame: %zu bytes", ws_pkt.len); + return ESP_ERR_INVALID_SIZE; } + + uint8_t buf[API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE]; + ws_pkt.payload = buf; + return httpd_ws_recv_frame(req, &ws_pkt, sizeof(buf)); } return ESP_OK; From 623682a040b770e7f25fc0b482cd070a43e64c82 Mon Sep 17 00:00:00 2001 From: Distortions81 Date: Tue, 4 Aug 2026 18:54:12 -0600 Subject: [PATCH 3/4] fix: authorize websocket clients before handshake --- main/http_server/websocket.c | 73 +++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/main/http_server/websocket.c b/main/http_server/websocket.c index 8e0c23ff03..b65f23ec47 100644 --- a/main/http_server/websocket.c +++ b/main/http_server/websocket.c @@ -13,6 +13,7 @@ #include "api_rx.h" #define WS_LOG_SCRATCH_SIZE 2048 +#define WS_HANDSHAKE_HEADER_SIZE 256 static const char * TAG = "websocket"; @@ -27,6 +28,57 @@ static SemaphoreHandle_t clients_mutex = NULL; static httpd_handle_t server_handle = NULL; static TaskHandle_t s_websocket_log_task_handle = NULL; +static bool websocket_has_free_slot(void) +{ + if (clients_mutex == NULL || + xSemaphoreTake(clients_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { + ESP_LOGE(TAG, "Failed to acquire mutex while checking client capacity"); + return false; + } + + bool has_free_slot = false; + for (int i = 0; i < MAX_WEBSOCKET_CLIENTS; i++) { + if (clients[i].fd == -1) { + has_free_slot = true; + break; + } + } + + xSemaphoreGive(clients_mutex); + return has_free_slot; +} + +static esp_err_t websocket_origin_is_allowed(httpd_req_t *req) +{ + size_t origin_len = httpd_req_get_hdr_value_len(req, "Origin"); + if (origin_len == 0) { + // Non-browser clients such as websocat do not necessarily send Origin. + return ESP_OK; + } + + size_t host_len = httpd_req_get_hdr_value_len(req, "Host"); + if (origin_len >= WS_HANDSHAKE_HEADER_SIZE || host_len == 0 || + host_len >= WS_HANDSHAKE_HEADER_SIZE) { + ESP_LOGW(TAG, "Rejecting WebSocket handshake with invalid Origin/Host length"); + return ESP_FAIL; + } + + char origin[WS_HANDSHAKE_HEADER_SIZE]; + char host[WS_HANDSHAKE_HEADER_SIZE]; + if (httpd_req_get_hdr_value_str(req, "Origin", origin, sizeof(origin)) != ESP_OK || + httpd_req_get_hdr_value_str(req, "Host", host, sizeof(host)) != ESP_OK) { + ESP_LOGW(TAG, "Rejecting WebSocket handshake with unreadable Origin/Host"); + return ESP_FAIL; + } + + if (!api_rx_websocket_origin_matches_host(origin, host)) { + ESP_LOGW(TAG, "Rejecting cross-origin WebSocket handshake"); + return ESP_FAIL; + } + + return ESP_OK; +} + void websocket_set_log_task_handle(TaskHandle_t task_handle) { s_websocket_log_task_handle = task_handle; @@ -176,17 +228,28 @@ void websocket_init(httpd_handle_t server) esp_err_t websocket_pre_handshake(httpd_req_t *req) { + if (websocket_origin_is_allowed(req) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, + "Forbidden WebSocket origin"); + return ESP_FAIL; + } + if (is_network_allowed(req) != ESP_OK) { httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); return ESP_FAIL; } - int active_clients = 0; - for (int i = 0; i < WS_TYPE_MAX; i++) { - active_clients += type_counts[i]; + WebSocketClientType type = (WebSocketClientType)(uintptr_t)req->user_ctx; + if (type < 0 || type >= WS_TYPE_MAX) { + ESP_LOGE(TAG, "Rejecting WebSocket connection with invalid client type: %d", + type); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Invalid WebSocket endpoint"); + return ESP_FAIL; } - if (active_clients >= MAX_WEBSOCKET_CLIENTS) { - ESP_LOGE(TAG, "Max WebSocket clients reached, rejecting new connection"); + + if (!websocket_has_free_slot()) { + ESP_LOGW(TAG, "Max WebSocket clients reached, rejecting handshake"); httpd_resp_send_custom_err(req, "429 Too Many Requests", "Max WebSocket clients reached"); return ESP_FAIL; } From 0db611e5eab3fe762ac63984b99fb6bfd3c4d819 Mon Sep 17 00:00:00 2001 From: Distortions81 Date: Fri, 7 Aug 2026 15:20:29 -0600 Subject: [PATCH 4/4] refactor: fold websocket rx helpers back into websocket module --- components/api_rx/CMakeLists.txt | 4 --- components/api_rx/api_rx.c | 36 ---------------------- components/api_rx/include/api_rx.h | 12 -------- components/api_rx/test/CMakeLists.txt | 5 --- components/api_rx/test/test_api_rx.c | 39 ------------------------ main/CMakeLists.txt | 1 - main/http_server/websocket.c | 44 ++++++++++++++++++++++++--- test/CMakeLists.txt | 2 +- 8 files changed, 41 insertions(+), 102 deletions(-) delete mode 100644 components/api_rx/CMakeLists.txt delete mode 100644 components/api_rx/api_rx.c delete mode 100644 components/api_rx/include/api_rx.h delete mode 100644 components/api_rx/test/CMakeLists.txt delete mode 100644 components/api_rx/test/test_api_rx.c diff --git a/components/api_rx/CMakeLists.txt b/components/api_rx/CMakeLists.txt deleted file mode 100644 index f9d5d6eb6b..0000000000 --- a/components/api_rx/CMakeLists.txt +++ /dev/null @@ -1,4 +0,0 @@ -idf_component_register( - SRCS "api_rx.c" - INCLUDE_DIRS "include" -) diff --git a/components/api_rx/api_rx.c b/components/api_rx/api_rx.c deleted file mode 100644 index 388e367792..0000000000 --- a/components/api_rx/api_rx.c +++ /dev/null @@ -1,36 +0,0 @@ -#include -#include - -#include "api_rx.h" - -bool api_rx_websocket_payload_fits(size_t payload_len) -{ - return payload_len <= API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE; -} - -bool api_rx_websocket_origin_matches_host(const char *origin, const char *host) -{ - if (origin == NULL || host == NULL || host[0] == '\0') { - return false; - } - - const char *authority = NULL; - static const char http_prefix[] = "http://"; - static const char https_prefix[] = "https://"; - - if (strncasecmp(origin, http_prefix, sizeof(http_prefix) - 1) == 0) { - authority = origin + sizeof(http_prefix) - 1; - } else if (strncasecmp(origin, https_prefix, sizeof(https_prefix) - 1) == 0) { - authority = origin + sizeof(https_prefix) - 1; - } else { - return false; - } - - size_t authority_len = strcspn(authority, "/?#"); - if (authority_len == 0 || authority[authority_len] != '\0') { - return false; - } - - size_t host_len = strlen(host); - return authority_len == host_len && strncasecmp(authority, host, host_len) == 0; -} diff --git a/components/api_rx/include/api_rx.h b/components/api_rx/include/api_rx.h deleted file mode 100644 index 2f00722ffc..0000000000 --- a/components/api_rx/include/api_rx.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef API_RX_H_ -#define API_RX_H_ - -#include -#include - -#define API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE 1024U - -bool api_rx_websocket_payload_fits(size_t payload_len); -bool api_rx_websocket_origin_matches_host(const char *origin, const char *host); - -#endif /* API_RX_H_ */ diff --git a/components/api_rx/test/CMakeLists.txt b/components/api_rx/test/CMakeLists.txt deleted file mode 100644 index 62886cf2a3..0000000000 --- a/components/api_rx/test/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -idf_component_register( - SRCS "test_api_rx.c" - INCLUDE_DIRS "." - REQUIRES unity api_rx -) diff --git a/components/api_rx/test/test_api_rx.c b/components/api_rx/test/test_api_rx.c deleted file mode 100644 index 8f12053cfd..0000000000 --- a/components/api_rx/test/test_api_rx.c +++ /dev/null @@ -1,39 +0,0 @@ -#include - -#include "api_rx.h" -#include "unity.h" - -TEST_CASE("WebSocket payload limit has strict boundary", "[api_rx]") -{ - TEST_ASSERT_TRUE(api_rx_websocket_payload_fits(0)); - TEST_ASSERT_TRUE(api_rx_websocket_payload_fits(API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE)); - TEST_ASSERT_FALSE(api_rx_websocket_payload_fits(API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE + 1U)); - TEST_ASSERT_FALSE(api_rx_websocket_payload_fits(SIZE_MAX)); -} - -TEST_CASE("WebSocket origin must match request host", "[api_rx]") -{ - TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( - "http://192.168.1.42", "192.168.1.42")); - TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( - "http://bitaxe.local", "bitaxe.local")); - TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( - "https://BITAXE.local", "bitaxe.LOCAL")); - TEST_ASSERT_TRUE(api_rx_websocket_origin_matches_host( - "http://bitaxe.local:8080", "bitaxe.local:8080")); - - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( - "http://evil.local", "bitaxe.local")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( - "http://bitaxe.local:8080", "bitaxe.local")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( - "null", "bitaxe.local")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( - "file://bitaxe.local", "bitaxe.local")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( - "http://bitaxe.local/", "bitaxe.local")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host( - "http://bitaxe.local@example.com", "example.com")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host(NULL, "bitaxe.local")); - TEST_ASSERT_FALSE(api_rx_websocket_origin_matches_host("http://bitaxe.local", NULL)); -} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 5a3a3b0727..917dd9274d 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -90,7 +90,6 @@ PRIV_REQUIRES "esp_driver_i2c" "esp_driver_uart" "tcp_transport" - "api_rx" "esp_mm" "bt" diff --git a/main/http_server/websocket.c b/main/http_server/websocket.c index b65f23ec47..ebfa5643f1 100644 --- a/main/http_server/websocket.c +++ b/main/http_server/websocket.c @@ -1,3 +1,6 @@ +#include +#include + #include #include #include "freertos/FreeRTOS.h" @@ -10,9 +13,9 @@ #include "websocket_api.h" #include "http_server.h" #include "log_buffer.h" -#include "api_rx.h" #define WS_LOG_SCRATCH_SIZE 2048 +#define WS_MAX_WEBSOCKET_PAYLOAD_SIZE 1024U #define WS_HANDSHAKE_HEADER_SIZE 256 static const char * TAG = "websocket"; @@ -28,6 +31,39 @@ static SemaphoreHandle_t clients_mutex = NULL; static httpd_handle_t server_handle = NULL; static TaskHandle_t s_websocket_log_task_handle = NULL; +static bool websocket_payload_fits(size_t payload_len) +{ + return payload_len <= WS_MAX_WEBSOCKET_PAYLOAD_SIZE; +} + +static bool websocket_origin_matches_host(const char *origin, const char *host) +{ + if (origin == NULL || host == NULL || host[0] == 0) { + return false; + } + + const char *authority = NULL; + static const char http_prefix[] = "http://"; + static const char https_prefix[] = "https://"; + + if (strncasecmp(origin, http_prefix, sizeof(http_prefix) - 1) == 0) { + authority = origin + sizeof(http_prefix) - 1; + } else if (strncasecmp(origin, https_prefix, sizeof(https_prefix) - 1) == 0) { + authority = origin + sizeof(https_prefix) - 1; + } else { + return false; + } + + size_t authority_len = strcspn(authority, "/?#"); + if (authority_len == 0 || authority[authority_len] != 0) { + return false; + } + + size_t host_len = strlen(host); + return authority_len == host_len && strncasecmp(authority, host, host_len) == 0; +} + + static bool websocket_has_free_slot(void) { if (clients_mutex == NULL || @@ -71,7 +107,7 @@ static esp_err_t websocket_origin_is_allowed(httpd_req_t *req) return ESP_FAIL; } - if (!api_rx_websocket_origin_matches_host(origin, host)) { + if (!websocket_origin_matches_host(origin, host)) { ESP_LOGW(TAG, "Rejecting cross-origin WebSocket handshake"); return ESP_FAIL; } @@ -289,12 +325,12 @@ esp_err_t websocket_handler(httpd_req_t *req) // WebSocket stream synchronized. Never allocate based on a peer-provided // frame length. if (ws_pkt.len > 0) { - if (!api_rx_websocket_payload_fits(ws_pkt.len)) { + if (!websocket_payload_fits(ws_pkt.len)) { ESP_LOGW(TAG, "Rejecting oversized WebSocket frame: %zu bytes", ws_pkt.len); return ESP_ERR_INVALID_SIZE; } - uint8_t buf[API_RX_MAX_WEBSOCKET_PAYLOAD_SIZE]; + uint8_t buf[WS_MAX_WEBSOCKET_PAYLOAD_SIZE]; ws_pkt.payload = buf; return httpd_ws_recv_frame(req, &ws_pkt, sizeof(buf)); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d972cfb1f3..e98ced1503 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,7 +10,7 @@ set(EXTRA_COMPONENT_DIRS "../components") # - when invoking CMake directly: cmake -D TEST_COMPONENTS="xxxxx" .. # - when using idf.py: idf.py -T xxxxx build # -set(TEST_COMPONENTS "stratum asic api_rx" CACHE STRING "List of components to test") +set(TEST_COMPONENTS "stratum asic" CACHE STRING "List of components to test") include($ENV{IDF_PATH}/tools/cmake/project.cmake)