Repository: Bluetooth-Devices/bluetooth-data-tools
Pull request #132: fix: reject data where the splice start position would be greater than end of the data
Author: login: bdraco
Created: 2025-04-02T07:30:13Z | Merged: 2025-04-02T07:47:49Z

We could end up with with the splice position after the `end` so it would segfault


Diff:
diff --git a/src/bluetooth_data_tools/gap.pxd b/src/bluetooth_data_tools/gap.pxd
index d1a94aa..bc49fd9 100644
--- a/src/bluetooth_data_tools/gap.pxd
+++ b/src/bluetooth_data_tools/gap.pxd
@@ -49,5 +49,6 @@ cpdef parse_advertisement_data(object data)
     offset=cython.uint,
     start=cython.uint,
     end=cython.uint,
+    splice_pos=cython.uint,
 )
 cpdef _uncached_parse_advertisement_data(tuple data)
diff --git a/src/bluetooth_data_tools/gap.py b/src/bluetooth_data_tools/gap.py
index e47da73..fb82eb8 100644
--- a/src/bluetooth_data_tools/gap.py
+++ b/src/bluetooth_data_tools/gap.py
@@ -193,11 +193,12 @@ def _uncached_parse_advertisement_data(
             elif gap_type_num == TYPE_COMPLETE_LOCAL_NAME:
                 local_name = gap_data[start:end].decode("utf-8", "replace")
             elif gap_type_num == TYPE_MANUFACTURER_SPECIFIC_DATA:
-                if start + 2 >= total_length:
+                splice_pos = start + 2
+                if splice_pos >= total_length or splice_pos >= end:
                     break
                 manufacturer_data[
-                    _cached_manufacturer_id_bytes_to_int(gap_data[start : start + 2])
-                ] = gap_data[start + 2 : end]
+                    _cached_manufacturer_id_bytes_to_int(gap_data[start:splice_pos])
+                ] = gap_data[splice_pos:end]
             elif gap_type_num in {
                 TYPE_16BIT_SERVICE_UUID_COMPLETE,
                 TYPE_16BIT_SERVICE_UUID_MORE_AVAILABLE,
@@ -209,23 +210,26 @@ def _uncached_parse_advertisement_data(
             }:
                 service_uuids.append(_cached_uint128_bytes_as_uuid(gap_data[start:end]))
             elif gap_type_num == TYPE_SERVICE_DATA:
-                if start + 2 >= total_length:
+                splice_pos = start + 2
+                if splice_pos >= total_length or splice_pos >= end:
                     break
                 service_data[
-                    _cached_uint16_bytes_as_uuid(gap_data[start : start + 2])
-                ] = gap_data[start + 2 : end]
+                    _cached_uint16_bytes_as_uuid(gap_data[start:splice_pos])
+                ] = gap_data[splice_pos:end]
             elif gap_type_num == TYPE_SERVICE_DATA_32BIT_UUID:
-                if start + 4 >= total_length:
+                splice_pos = start + 4
+                if splice_pos >= total_length or splice_pos >= end:
                     break
                 service_data[
-                    _cached_uint32_bytes_as_uuid(gap_data[start : start + 4])
-                ] = gap_data[start + 4 : end]
+                    _cached_uint32_bytes_as_uuid(gap_data[start:splice_pos])
+                ] = gap_data[splice_pos:end]
             elif gap_type_num == TYPE_SERVICE_DATA_128BIT_UUID:
-                if start + 16 >= total_length:
+                splice_pos = start + 16
+                if splice_pos >= total_length or splice_pos >= end:
                     break
                 service_data[
-                    _cached_uint128_bytes_as_uuid(gap_data[start : start + 16])
-                ] = gap_data[start + 16 : end]
+                    _cached_uint128_bytes_as_uuid(gap_data[start:splice_pos])
+                ] = gap_data[splice_pos:end]
             elif gap_type_num == TYPE_TX_POWER_LEVEL:
                 tx_power = _cached_from_bytes_signed(gap_data[start:end])
 
diff --git a/tests/test_gap.py b/tests/test_gap.py
index db25f20..e1a81f1 100644
--- a/tests/test_gap.py
+++ b/tests/test_gap.py
@@ -1,5 +1,7 @@
 import base64
 
+import pytest
+
 from bluetooth_data_tools import (
     parse_advertisement_data,
     parse_advertisement_data_tuple,
@@ -667,3 +669,88 @@ def test_data_shorter_than_length() -> None:
             {},
             None,
         )
+
+
+@pytest.mark.parametrize(
+    "data",
+    [
+        (b"\x02 a\xc4|\x04@*'\x9c\xa5C\r\xa1\xe6\x1e\xe7\x8f\xa57D\xe6$\x03",),
+        (
+            b"\xe8\x9d\x83\x8b\x96\x85\x07U\x19$&\x1c\x91\x80\xba\x04Z",
+            b'"EU\xcb\x11\xeb\xdc\xd4)\xc9\x1b\x8c\xfe\x1e\xd7\xca\x98\xfe\xcd%\xf6>\xbb\xe3\xbc\xfc5',
+            b"(\x86\x18\xe9m>\x89\xa7\xe1=\x9fE\x0f\xbcE\xb2<",
+        ),
+        (
+            b"\xf4\x9e\x0f!\x93n\xae\x1e\x89\xc2\xb4\x97\x98\xdc~\x9a\xb3\x06\x7f\x11",
+            b"\x89\xc2\x07\xff\xe7QEG&;\x06\xab\xebN\xf5\xc4g\xfe",
+            b"\xeb\x852\x0b,KB",
+        ),
+        (
+            b'\xcbu\xa5\xe4\xa1\xdd`c\x88\xa9\xe2\xaah\xe5"%X\x87\xf0',
+            b'\xac;\x8f\xc43!\xb0?"\xb8\n<',
+            b"\x10nyT\xed\x96\x07\x9a",
+        ),
+        (
+            b"\r\xd6'\xder\xfc@T\xb3Q\x12EH\x9e\xe9\xf8x\xe3\xcc\ti\xfb",
+            b"\xfe@\x89",
+            b"Gt\xc3_\xf9\x8e\xdd#",
+        ),
+        (
+            b'\x17b\x02c\xbe%"\x06\xfe\x05c4\x04\x97\x03)|\xd0\xf4\xeb~\xb1gI',
+            b"r\xc0\x9a>^o\xcb\xdb\xc2r\xca\x1a'VL\xac\xd7\x8e\xa7\xeb\xc2:\xe2_\x96\xe31r",
+            b"\\^\xe6\x96]%\x17\x02\xa5\xdcAE\x92\xb2\x1c\x1a\xd4Y\x07\xddZ\xebC\x12B\xb6\xcb\x8a",
+        ),
+        (
+            b"\x1b\x87c\xb7\x12nK\xf3_\xb4\x18)x<\xe2\x05\xc8\xdb\xf8\x1b\xf7 \xe6\x99\xe3Z\xd1C\x88\xda\x98",
+            b"\xbb\xbe@,\xa6\xdcj*\x85u\xe3\xc4",
+            b"w\xb7\xeb\xb0",
+        ),
+        (
+            b"\x88\x83\x7fN\xd0E\xa6\xc5(h\xcd\xe7\x80\\\xf0\xe6n:\x10K\x02\x92",
+            b"\xc5\xe6`a\xf7\xf5 \x1eV\xd6]8$\xf0\xf0",
+            b"\x01rS\xf1\xe2ht\xd6\x8f\xc6\x0e\xd0~",
+        ),
+        (
+            b"\x10\xa6\xc4}\xb6\xc9n\x00",
+            b"\xa8\xd8#'@\xde\x9a\xf6;&\x88\x0f\xc1m\xe5v\xa3\x17O\x8b\x92\x94\\\x91\x8b\xee\xa1\xed",
+            b'\xccA\x9c\x01;\xd1\xe8e\xeb\xf3\x8b\xa1\xd6\xe5P]\xc7"_@\x10\x1e\x8b\x90o\r\xd9',
+        ),
+    ],
+)
+def test_negative_splice_pos_does_not_crash(data: tuple[bytes, bytes, bytes]) -> None:
+    """Test data that would cause a negative splice position.
+
+    All of the data here will result in a negative splice position
+    when attempting to parse the advertisement data. This is a
+    regression test to ensure that the parser does not crash
+    when it encounters this data.
+
+    A negative splice position is caused by the parser
+    attempting to splice the data at a position that is
+    greater than the length of the data. This can happen
+    when the data is malformed or when the parser is
+    attempting to parse data that is not in the expected
+    format. The parser should be able to handle this
+    gracefully and return an empty advertisement object
+    without crashing.
+    """
+
+    adv = parse_advertisement_data(data)
+
+    assert adv.local_name is None
+    assert adv.service_uuids == []
+    assert adv.service_data == {}
+    assert adv.manufacturer_data == {}
+    assert adv.tx_power is None
+
+    assert parse_advertisement_data_tuple(
+        tuple(
+            data,
+        )
+    ) == (
+        None,
+        [],
+        {},
+        {},
+        None,
+    )


Reviews:
user: login: emontnemery
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-02T07:39:02Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T07:41:56Z

Review comments:
diff_hunk: @@ -667,3 +667,29 @@ def test_data_shorter_than_length() -> None:
             {},
             None,
         )
+
+
+def test_data_shorter_than_required_length():
+    """Test data that is too short."""
+
+    data = (b"\x02 a\xc4|\x04@*'\x9c\xa5C\r\xa1\xe6\x1e\xe7\x8f\xa57D\xe6$\x03",)
path: tests/test_gap.py
user: login: emontnemery
body: Maybe parametrize the test to test more of the error paths in `_uncached_parse_advertisement_data`, or does this somehow trigger all the error paths?
Also:
- Consider adding a comment for the data to mention how it's invalid
- I find the representation as mixed ascii and escaped hex to be really hard to read, but maybe that doesn't matter
created_at: 2025-04-02T07:39:02Z
author_association: NONE
diff_hunk: @@ -667,3 +667,29 @@ def test_data_shorter_than_length() -> None:
             {},
             None,
         )
+
+
+def test_data_shorter_than_required_length():
+    """Test data that is too short."""
+
+    data = (b"\x02 a\xc4|\x04@*'\x9c\xa5C\r\xa1\xe6\x1e\xe7\x8f\xa57D\xe6$\x03",)
path: tests/test_gap.py
user: login: bdraco
body: I just pushed a parametrize with all the bad data I discovered
created_at: 2025-04-02T07:41:56Z
author_association: MEMBER

PR comments:
user: login: codspeed-hq[bot]
created_at: 2025-04-02T07:32:34Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/Bluetooth-Devices/bluetooth-data-tools/branches/fix_split_point_gt_end)

### Merging #132 will **not alter performance**

<sub>Comparing <code>fix_split_point_gt_end</code> (b11c12b) with <code>main</code> (5249fe9)</sub>



### Summary

`✅ 5` untouched benchmarks  




author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-02T07:33:26Z
body: ## [Codecov](https://app.codecov.io/gh/Bluetooth-Devices/bluetooth-data-tools/pull/132?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bluetooth-Devices) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`5249fe9`)](https://app.codecov.io/gh/Bluetooth-Devices/bluetooth-data-tools/commit/5249fe9374d173cf32c96c3755702f026c780457?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bluetooth-Devices) to head [(`b11c12b`)](https://app.codecov.io/gh/Bluetooth-Devices/bluetooth-data-tools/commit/b11c12b17ebd04cf6bf46ed1f2a33209c1410824?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bluetooth-Devices).
> Report is 21 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #132   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            6         6           
  Lines          223       227    +4     
  Branches        27        27           
=========================================
+ Hits           223       227    +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Bluetooth-Devices/bluetooth-data-tools/pull/132?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bluetooth-Devices).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bluetooth-Devices).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE