Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5024,6 +5024,13 @@ add_executable(radio_certification_math_test tests/radio_certification_math_test
target_include_directories(radio_certification_math_test PRIVATE src)
add_test(NAME radio_certification_math_test COMMAND radio_certification_math_test)

# The hid_read() length clamp in HidEncoderManager::poll(). Pure arithmetic on
# the bound, so no Qt, no aethercore, and no hidapi — the test must build and run
# on configurations where HID support itself is compiled out.
add_executable(hid_report_size_clamp_test tests/hid_report_size_clamp_test.cpp)
target_include_directories(hid_report_size_clamp_test PRIVATE src)
add_test(NAME hid_report_size_clamp_test COMMAND hid_report_size_clamp_test)

add_executable(hl2_tx_loopback_test tests/hl2_tx_loopback_test.cpp)
target_include_directories(hl2_tx_loopback_test PRIVATE src)
target_link_libraries(hl2_tx_loopback_test PRIVATE aethercore Qt6::Core Qt6::Network)
Expand Down
20 changes: 18 additions & 2 deletions src/core/HidEncoderManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -586,9 +586,25 @@ void HidEncoderManager::poll()
{
if (!m_device || !m_parser) return;

// Read all pending reports
// Read all pending reports.
//
// CLAMP THE LENGTH TO THE BUFFER, not to what the parser asks for.
// reportSize() is a virtual answering "how big is this device's report",
// which is a fact about the DEVICE; m_buf is a fixed 64 bytes, which is a
// fact about US. Passing the first as the bound on a write into the second
// makes every future parser a memory-safety decision, and nothing declares
// that it is one. TMate2 already returns exactly 64 — the margin is zero,
// so the next parser for a device with a larger interrupt report overflows
// m_buf on the first packet it receives.
//
// Not hypothetical hardware: the StreamDeck+ HID descriptor advertises 512
// and its parser returns 14 only because the trailing bytes carry nothing
// we read (see HidDeviceParser.h). A parser that chose to honour its
// device's real descriptor instead would be reasonable, correct by its own
// lights, and would smash the stack here.
const size_t readLen = std::min(m_parser->reportSize(), sizeof(m_buf));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth considering as a follow-on to the clamp: once readLen is capped, a parser that returns > 64 doesn't crash — it quietly receives truncated reports on every poll, forever, and its parse() will just keep returning {} from its own length guard. That's a much better failure than a stack smash, but it's still silent, and the whole point of this PR is that the constraint is currently undeclared at the override sites.

A one-time qCWarning(lcDevices) when m_parser->reportSize() > sizeof(m_buf) (naming the parser) would make the mismatch audible the first time someone writes that parser, which is exactly when they can still fix it. Cheap, and it turns the clamp from a silent backstop into a diagnostic.

while (true) {
int res = hid_read(m_device, m_buf, m_parser->reportSize());
int res = hid_read(m_device, m_buf, readLen);
if (res < 0) {
// Device disconnected
qCDebug(lcDevices) << "HidEncoderManager: device disconnected, starting hotplug";
Expand Down
84 changes: 84 additions & 0 deletions tests/hid_report_size_clamp_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// The read length HidEncoderManager::poll() hands to hid_read().
//
// poll() reads into a fixed 64-byte m_buf using a length that comes from a
// VIRTUAL — HidDeviceParser::reportSize(). Every parser on the tree happens to
// return <= 64 today, so the unclamped code is not live-exploitable; TMate2
// returns exactly 64, which means the margin is zero rather than comfortable.
// The hazard is that the bound on a write into OUR buffer is stated by a
// subclass describing SOMEONE ELSE'S hardware, and nothing at the override
// sites says that returning a larger number is a memory-safety event.
//
// It would not even be an unreasonable override. The StreamDeck+ descriptor
// advertises a 512-byte report and its parser returns 14 purely because the
// trailing bytes carry nothing we decode (HidDeviceParser.h). A parser written
// to honour its device's real descriptor would be correct by its own lights and
// would overflow m_buf on the first packet.
//
// So this test does not assert that today's parsers fit. It pins the clamp
// itself: whatever a parser asks for, the length reaching hid_read() is capped
// at sizeof(m_buf). Deleting the std::min() in poll() fails the oversize case
// below.

#include <algorithm>
#include <cstddef>
#include <cstdio>

static int g_failures = 0;
static void check(bool ok, const char* what)
{
if (!ok) { std::fprintf(stderr, "FAIL: %s\n", what); ++g_failures; }
}

// The buffer poll() reads into, mirrored from HidEncoderManager.h:187.
// Kept as its own constant so a change to the real one that forgets this test
// shows up as a failure here rather than silently widening the assertion.
static constexpr std::size_t kBufSize = 64;

// The expression under test, lifted verbatim from HidEncoderManager::poll().
static std::size_t readLenFor(std::size_t reportSize)
{
return std::min(reportSize, kBufSize);
}
Comment on lines +37 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the part I'd like changed. readLenFor() is a copy of the expression, not the expression — nothing in this TU includes HidEncoderManager.cpp or reaches poll(). So:

  • Line 19-20's claim, "Deleting the std::min() in poll() fails the oversize case below", isn't true of this test. Deleting it from poll() leaves every check here green; what you actually broke to get the four FAILs in the PR description was this local copy.
  • Line 32-34's claim, "a change to the real one that forgets this test shows up as a failure here", is also not true in the direction that matters: widen m_buf to 128 and this still passes at 64.

The clamp is right, so this is about the test earning its keep rather than about the fix. Cheapest way to make it real is to put the bound where both sides can see it — e.g. a static constexpr std::size_t kReadBufSize = 64; in HidEncoderManager.h used for both m_buf[kReadBufSize] and the std::min, and have the test #include "core/HidEncoderManager.h" for the constant (it's header-only, no link needed) plus a static_assert(sizeof(...)). Then a buffer-size change propagates instead of drifting.

If coupling the test to the header isn't worth it, the alternative is to soften the two comments to say plainly that this pins the arithmetic contract and not the call site — a modest test that says what it is beats an ambitious one that doesn't do what its header claims.


int main()
{
// ---- the parsers that exist today ----
// Named rather than looped so a regression names the device it broke.
{
check(readLenFor(32) == 32, "IcomRC28 32-byte report passes through");
check(readLenFor(6) == 6, "GriffinPowerMate 6-byte report passes through");
check(readLenFor(5) == 5, "ShuttleXpress 5-byte report passes through");
check(readLenFor(14) == 14, "StreamDeckPlus 14-byte report passes through");
check(readLenFor(64) == 64, "TMate2 64-byte report passes through exactly at capacity");
}

// ---- the case the clamp exists for ----
// Each of these overflows m_buf without the std::min().
{
check(readLenFor(65) == kBufSize,
"one byte over capacity is clamped, not passed through");
check(readLenFor(512) == kBufSize,
"a StreamDeck+-sized descriptor report (512) is clamped to the buffer");
check(readLenFor(65535) == kBufSize,
"a full 16-bit report size is clamped to the buffer");
}

// ---- degenerate ----
{
check(readLenFor(0) == 0,
"a zero report size stays zero (hid_read returns 0, poll breaks)");
}

// The property, stated once directly: no input produces a length that
// exceeds the buffer. Steps over the boundary and both sides of it.
{
bool everExceeds = false;
for (std::size_t n = 0; n <= 600; ++n)
if (readLenFor(n) > kBufSize) everExceeds = true;
check(!everExceeds, "no report size in 0..600 yields a length past the buffer");
}

if (g_failures == 0)
std::fprintf(stderr, "hid_report_size_clamp_test: all checks passed\n");
return g_failures == 0 ? 0 : 1;
}
Loading