From 847af70bb4013643f68b1e151d434e5f53656004 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Mon, 5 Oct 2026 04:01:19 +0530 Subject: [PATCH] gatt: enforce value size and queue limits on server long writes The Prepare/Execute Write path bypassed the 512-byte attribute value limit that Write Request enforces, and queued prepared writes without bound. - Execute Write Request now fails with INVALID_ATTRIBUTE_LENGTH when a reassembled value exceeds GATT_MAX_ATTRIBUTE_VALUE_SIZE; nothing is written. - Prepare Write Request now fails with PREPARE_QUEUE_FULL once GATT_SERVER_MAX_PREPARED_WRITES (64) parts are queued for a bearer. --- bumble/gatt_server.py | 22 ++++++++++++-- tests/gatt_test.py | 70 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py index ee7ac7ba..7ee89f90 100644 --- a/bumble/gatt_server.py +++ b/bumble/gatt_server.py @@ -60,6 +60,7 @@ # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- +GATT_SERVER_MAX_PREPARED_WRITES = 64 # Max queued Prepare Write Requests per bearer GATT_SERVER_DEFAULT_MAX_MTU = 517 @@ -1176,7 +1177,18 @@ def on_att_prepare_write_request( return # Queue the partial value, to be committed on Execute Write Request - self.prepared_writes.setdefault(bearer, []).append( + queue = self.prepared_writes.setdefault(bearer, []) + if len(queue) >= GATT_SERVER_MAX_PREPARED_WRITES: + self.send_response( + bearer, + att.ATT_Error_Response( + request_opcode_in_error=request.op_code, + attribute_handle_in_error=request.attribute_handle, + error_code=att.ATT_PREPARE_QUEUE_FULL_ERROR, + ), + ) + return + queue.append( ( request.attribute_handle, request.value_offset, @@ -1210,7 +1222,8 @@ async def on_att_execute_write_request( return try: - # Reassemble the queued parts; reject a gap with INVALID_OFFSET + # Reassemble the queued parts; reject a gap with INVALID_OFFSET and + # an oversized value with INVALID_ATTRIBUTE_LENGTH values: dict[int, bytearray] = {} for handle, offset, part in queue: buffer = values.setdefault(handle, bytearray()) @@ -1219,6 +1232,11 @@ async def on_att_execute_write_request( error_code=att.ATT_INVALID_OFFSET_ERROR, att_handle=handle ) buffer[offset : offset + len(part)] = part + if len(buffer) > GATT_MAX_ATTRIBUTE_VALUE_SIZE: + raise att.ATT_Error( + error_code=att.ATT_INVALID_ATTRIBUTE_LENGTH_ERROR, + att_handle=handle, + ) # Commit the reassembled values through the normal write path for handle, value in values.items(): diff --git a/tests/gatt_test.py b/tests/gatt_test.py index 64cfa7c4..f2a6b694 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -28,7 +28,7 @@ import pytest from typing_extensions import Self -from bumble import att, gatt_client, l2cap +from bumble import att, gatt_client, gatt_server, l2cap from bumble.att import ( ATT_ATTRIBUTE_NOT_FOUND_ERROR, ATT_PDU, @@ -44,6 +44,7 @@ from bumble.gatt import ( GATT_BATTERY_LEVEL_CHARACTERISTIC, GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR, + GATT_MAX_ATTRIBUTE_VALUE_SIZE, Characteristic, CharacteristicValue, Descriptor, @@ -1840,6 +1841,73 @@ async def test_write_long_value_cancel(): assert characteristic.value is None +# ----------------------------------------------------------------------------- +async def _prepare_write_long_value(client, handle, value): + part_size = client.mtu - 5 + for offset in range(0, len(value), part_size): + response = await client.send_request( + att.ATT_Prepare_Write_Request( + attribute_handle=handle, + value_offset=offset, + part_attribute_value=value[offset : offset + part_size], + ) + ) + assert isinstance(response, att.ATT_Prepare_Write_Response) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_write_long_value_too_long_rejected(): + peer, _, characteristic = await _connect_with_writeable_characteristic() + client = peer.gatt_client + + # A value of exactly the maximum attribute value size is accepted + max_value = bytes(i % 256 for i in range(GATT_MAX_ATTRIBUTE_VALUE_SIZE)) + await _prepare_write_long_value(client, characteristic.handle, max_value) + response = await client.send_request(att.ATT_Execute_Write_Request(flags=0x01)) + assert isinstance(response, att.ATT_Execute_Write_Response) + await async_barrier() + assert characteristic.value == max_value + + # One byte more is rejected with INVALID_ATTRIBUTE_LENGTH, and not written + await _prepare_write_long_value(client, characteristic.handle, max_value + b'\0') + response = await client.send_request(att.ATT_Execute_Write_Request(flags=0x01)) + assert isinstance(response, att.ATT_Error_Response) + assert response.error_code == att.ATT_INVALID_ATTRIBUTE_LENGTH_ERROR + assert response.attribute_handle_in_error == characteristic.handle + await async_barrier() + assert characteristic.value == max_value + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_write_long_value_queue_full(): + peer, _, characteristic = await _connect_with_writeable_characteristic() + client = peer.gatt_client + + request = att.ATT_Prepare_Write_Request( + attribute_handle=characteristic.handle, + value_offset=0, + part_attribute_value=bytes([1, 2, 3, 4]), + ) + + # Fill the queue + for _ in range(gatt_server.GATT_SERVER_MAX_PREPARED_WRITES): + response = await client.send_request(request) + assert isinstance(response, att.ATT_Prepare_Write_Response) + + # One more is rejected with PREPARE_QUEUE_FULL + response = await client.send_request(request) + assert isinstance(response, att.ATT_Error_Response) + assert response.error_code == att.ATT_PREPARE_QUEUE_FULL_ERROR + + # Cancelling empties the queue, which then accepts new parts + response = await client.send_request(att.ATT_Execute_Write_Request(flags=0x00)) + assert isinstance(response, att.ATT_Execute_Write_Response) + response = await client.send_request(request) + assert isinstance(response, att.ATT_Prepare_Write_Response) + + # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_write_long_value_gap_rejected():