Skip to content

hid: don't raise on control PDUs a peer may legitimately send - #979

Open
rajathpi wants to merge 1 commit into
google:mainfrom
rajathpi:fix/hid-control-pdu-robustness
Open

hid: don't raise on control PDUs a peer may legitimately send#979
rajathpi wants to merge 1 commit into
google:mainfrom
rajathpi:fix/hid-control-pdu-robustness

Conversation

@rajathpi

Copy link
Copy Markdown

What

Four control PDUs that a remote peer can send cause bumble/hid.py to raise out of the L2CAP receive path.

ClassicChannel.on_sdu calls the channel sink without a try/except, so the exception escapes into the transport's receive loop. On real transports it is caught and logged one layer up (transport/common.py, transport/usb.py), so nothing crashes — but the PDU is discarded and, for the report requests, the mandatory HANDSHAKE response is never sent, leaving the requesting peer waiting.

PDU received Before
HANDSHAKE, result code 0x050x0D (to Host) ValueError, no EVENT_HANDSHAKE emitted
empty PDU (to Host or Device) IndexError
GET_REPORT shorter than the implemented format IndexError, no handshake reply
SET_REPORT shorter than the implemented format IndexError, no handshake reply

Result codes 0x050x0D are reserved by the HID Profile 1.1 specification (3.1.2.1, Table 3.2), so a peer can legitimately send one — but Message.Handshake was a plain enum.IntEnum naming only 0x000x04, 0x0E and 0x0F.

How

  • Message.Handshake becomes a utils.OpenIntEnum. That is the idiom already used for protocol enums throughout the codebase (~160 other uses), and it keeps .name working for unnamed values.
  • Empty control PDUs are logged and dropped instead of being indexed.
  • A GET_REPORT/SET_REPORT too short to carry the fields Bumble reads is answered with ERR_INVALID_PARAMETER, per 3.1.2.1 ("If [the device] detects a field with a value that is out of range or inappropriate for the request, then an ERR_INVALID_PARAMETER result code shall be returned").

The length check is len(pdu) < (4 if buffer_flag else 2), matching Table 3.4: HIDP header, report ID, then a 2-octet little-endian buffer size only when the Size bit is set. Note the report ID is conditional in the spec, but Bumble's API always carries one (GetReportMessage.__bytes__, get_report_cb(report_id, ...)), so a shorter PDU cannot be dispatched to the application callback without an API change.

No behaviour change for well-formed PDUs, and the no-callback-registered path still answers ERR_UNSUPPORTED_REQUEST exactly as before.

Tests

Adds tests/hid_test.pyhid.py had no test coverage. The tests drive the PDUs across a real L2CAP channel between two TwoDevices peers rather than calling on_ctrl_pdu directly, and the empty-PDU case installs a loop exception handler, since that failure surfaces on the event loop rather than at the call site.

16 of the 18 tests fail on main and pass with this change. The 2 that pass either way are the deliberate no-regression checks (a defined handshake code, and a well-formed GET_REPORT round trip).

Full suite: 994 passed. The 6 android-netsim failures are pre-existing and environmental (grpcio not installed). black -S --check, ruff check, ruff check --select I and mypy are clean on both files.

Note

#815 reworks this file and reaches the same conclusion about OpenIntEnum for these enums. This change is deliberately small and non-breaking so it can land on main independently; it should be trivially absorbed if that refactor lands.

🤖 Generated with Claude Code

Handshake result codes 0x05..0x0D are reserved by the HID Profile 1.1
specification (3.1.2.1, Table 3.2), but `Message.Handshake` was a plain
`enum.IntEnum` naming only 0x00..0x04, 0x0E and 0x0F. Receiving a
reserved code raised `ValueError`, and no handshake event was emitted.
Make it a `utils.OpenIntEnum`, as already used for protocol enums
elsewhere in the codebase.

An empty control PDU, and a GET_REPORT or SET_REPORT shorter than the
report-ID-carrying format Bumble implements, raised `IndexError` while
indexing the PDU. Empty PDUs are now dropped, and a short GET/SET_REPORT
is answered with ERR_INVALID_PARAMETER.

`ClassicChannel.on_sdu` calls the channel sink without a try/except, so
these exceptions propagate out of the L2CAP receive path. The transport
layer logs them and discards the PDU, which leaves a requesting peer
waiting for a handshake that is never sent.

Adds tests/hid_test.py; hid.py had no test coverage before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@google-cla

google-cla Bot commented Sep 11, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@zxzxwu

zxzxwu commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

FYI, we are going to refactor HID soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants