From 6b83e5508852f1dcb14965849e5861c687085cc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 09:58:36 -0500 Subject: [PATCH] [api] Add message size limits to prevent memory exhaustion --- esphome/components/api/api_frame_helper.h | 10 ++++++ .../components/api/api_frame_helper_noise.cpp | 7 ++++ .../api/api_frame_helper_plaintext.cpp | 4 +-- tests/integration/test_oversized_payloads.py | 32 ++++++++++--------- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index c11d701ffe..fb0147a70b 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -17,6 +17,16 @@ namespace esphome::api { // uncomment to log raw packets //#define HELPER_LOG_PACKETS +// Maximum message size limits to prevent OOM on constrained devices +// Voice Assistant is our largest user at 1024 bytes per audio chunk +// Using 2048 + 256 bytes overhead = 2304 bytes total to support voice and future needs +// ESP8266 has very limited RAM and cannot support voice assistant +#ifdef USE_ESP8266 +static constexpr uint16_t MAX_MESSAGE_SIZE = 512; // Keep small for memory constrained ESP8266 +#else +static constexpr uint16_t MAX_MESSAGE_SIZE = 2304; // Support voice (1024) + headroom for larger messages +#endif + // Forward declaration struct ClientInfo; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0e49f93db5..b77af43cc2 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -184,6 +184,13 @@ APIError APINoiseFrameHelper::try_read_frame_(std::vector *frame) { return APIError::BAD_HANDSHAKE_PACKET_LEN; } + // Check against maximum message size to prevent OOM + if (msg_size > MAX_MESSAGE_SIZE) { + state_ = State::FAILED; + HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, MAX_MESSAGE_SIZE); + return APIError::BAD_DATA_PACKET; + } + // reserve space for body if (rx_buf_.size() != msg_size) { rx_buf_.resize(msg_size); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 859bb26630..ef723274be 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -122,10 +122,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_(std::vector *frame) { continue; } - if (msg_size_varint->as_uint32() > std::numeric_limits::max()) { + if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) { state_ = State::FAILED; HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), - std::numeric_limits::max()); + MAX_MESSAGE_SIZE); return APIError::BAD_DATA_PACKET; } rx_header_parsed_len_ = msg_size_varint->as_uint16(); diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py index f3e422620c..22167118af 100644 --- a/tests/integration/test_oversized_payloads.py +++ b/tests/integration/test_oversized_payloads.py @@ -15,7 +15,7 @@ async def test_oversized_payload_plaintext( run_compiled: RunCompiledFunction, api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, ) -> None: - """Test that oversized payloads (>100KiB) from client cause disconnection without crashing.""" + """Test that oversized payloads (>2304 bytes) from client cause disconnection without crashing.""" process_exited = False helper_log_found = False @@ -39,8 +39,8 @@ async def test_oversized_payload_plaintext( assert device_info is not None assert device_info.name == "oversized-plaintext" - # Create an oversized payload (>100KiB) - oversized_data = b"X" * (100 * 1024 + 1) # 100KiB + 1 byte + # Create an oversized payload (>2304 bytes which is our new limit) + oversized_data = b"X" * 3000 # ~3KiB, exceeds the 2304 byte limit # Access the internal connection to send raw data frame_helper = client._connection._frame_helper @@ -132,22 +132,24 @@ async def test_oversized_payload_noise( run_compiled: RunCompiledFunction, api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, ) -> None: - """Test that oversized payloads (>100KiB) from client cause disconnection without crashing with noise encryption.""" + """Test that oversized payloads from client cause disconnection without crashing with noise encryption.""" noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" process_exited = False - cipherstate_failed = False + helper_log_found = False def check_logs(line: str) -> None: - nonlocal process_exited, cipherstate_failed + nonlocal process_exited, helper_log_found # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True - # Check for the expected warning about decryption failure + # Check for HELPER_LOG message about message size exceeding maximum + # With our new protection, oversized messages are rejected at frame level if ( - "[W][api.connection" in line - and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line + "[VV]" in line + and "Bad packet: message size" in line + and "exceeds maximum" in line ): - cipherstate_failed = True + helper_log_found = True async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -159,8 +161,8 @@ async def test_oversized_payload_noise( assert device_info is not None assert device_info.name == "oversized-noise" - # Create an oversized payload (>100KiB) - oversized_data = b"Y" * (100 * 1024 + 1) # 100KiB + 1 byte + # Create an oversized payload (>2304 bytes which is our new limit) + oversized_data = b"Y" * 3000 # ~3KiB, exceeds the 2304 byte limit # Access the internal connection to send raw data frame_helper = client._connection._frame_helper @@ -175,9 +177,9 @@ async def test_oversized_payload_noise( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected warning message - assert cipherstate_failed, ( - "Expected to see warning about CIPHERSTATE_DECRYPT_FAILED" + # Verify we saw the expected HELPER_LOG message + assert helper_log_found, ( + "Expected to see HELPER_LOG about message size exceeding maximum" ) # Try to reconnect to verify the process is still running