mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	[api] Add message size limits to prevent memory exhaustion (#10936)
This commit is contained in:
		| @@ -18,6 +18,17 @@ namespace esphome::api { | ||||
| // uncomment to log raw packets | ||||
| //#define HELPER_LOG_PACKETS | ||||
|  | ||||
| // Maximum message size limits to prevent OOM on constrained devices | ||||
| // Handshake messages are limited to a small size for security | ||||
| static constexpr uint16_t MAX_HANDSHAKE_SIZE = 128; | ||||
|  | ||||
| // Data message limits vary by platform based on available memory | ||||
| #ifdef USE_ESP8266 | ||||
| static constexpr uint16_t MAX_MESSAGE_SIZE = 8192;  // 8 KiB for ESP8266 | ||||
| #else | ||||
| static constexpr uint16_t MAX_MESSAGE_SIZE = 32768;  // 32 KiB for ESP32 and other platforms | ||||
| #endif | ||||
|  | ||||
| // Forward declaration | ||||
| struct ClientInfo; | ||||
|  | ||||
|   | ||||
| @@ -168,11 +168,12 @@ APIError APINoiseFrameHelper::try_read_frame_() { | ||||
|   // read body | ||||
|   uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2]; | ||||
|  | ||||
|   if (state_ != State::DATA && msg_size > 128) { | ||||
|     // for handshake message only permit up to 128 bytes | ||||
|   // Check against size limits to prevent OOM: MAX_HANDSHAKE_SIZE for handshake, MAX_MESSAGE_SIZE for data | ||||
|   uint16_t limit = (state_ == State::DATA) ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE; | ||||
|   if (msg_size > limit) { | ||||
|     state_ = State::FAILED; | ||||
|     HELPER_LOG("Bad packet len for handshake: %d", msg_size); | ||||
|     return APIError::BAD_HANDSHAKE_PACKET_LEN; | ||||
|     HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, limit); | ||||
|     return (state_ == State::DATA) ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN; | ||||
|   } | ||||
|  | ||||
|   // Reserve space for body | ||||
|   | ||||
| @@ -115,10 +115,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { | ||||
|       continue; | ||||
|     } | ||||
|  | ||||
|     if (msg_size_varint->as_uint32() > std::numeric_limits<uint16_t>::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<uint16_t>::max()); | ||||
|                  MAX_MESSAGE_SIZE); | ||||
|       return APIError::BAD_DATA_PACKET; | ||||
|     } | ||||
|     rx_header_parsed_len_ = msg_size_varint->as_uint16(); | ||||
|   | ||||
| @@ -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 (>32768 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 (>32768 bytes which is our new limit) | ||||
|             oversized_data = b"X" * 40000  # ~40KiB, exceeds the 32768 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 (>32768 bytes which is our new limit) | ||||
|             oversized_data = b"Y" * 40000  # ~40KiB, exceeds the 32768 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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user