Skip to content

feat(rdpeusb)!: tolerate unrecognized device-reported USB capability values#1418

Open
clintcan wants to merge 1 commit into
Devolutions:masterfrom
clintcan:feat/rdpeusb-lenient-caps
Open

feat(rdpeusb)!: tolerate unrecognized device-reported USB capability values#1418
clintcan wants to merge 1 commit into
Devolutions:masterfrom
clintcan:feat/rdpeusb-lenient-caps

Conversation

@clintcan

@clintcan clintcan commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Problem

UsbDeviceCaps::decode (MS-RDPEUSB USB_DEVICE_CAPABILITIES, 2.2.11) rejects any
value it doesn't explicitly name for the four device-reported capability
fields — UsbBusInterfaceVersion, USBDI_Version, SupportedUsbVersion,
DeviceIsHighSpeed. In particular SupportedUsbVersion only knew USB 1.0/1.1/2.0
(0x100/0x110/0x200).

A real USB 3.2 device reports SupportedUsbVersion = 0x320, which isn't named,
so decoding its ADD_DEVICE returns unsupported_value_err! — and that error
propagates out of the DVC processor, tearing down the URBDRC channel. A modern
USB-3 device therefore can't be redirected at all.

Fix

Make the four device-reported enums data-carrying: the named values plus an
Other(u32) fallback (with from_u32/to_u32 helpers replacing the
#[repr(u32)] + as u32 encode), so an unrecognized value is preserved verbatim
instead of failing the decode. SupportedUsbVer additionally names the USB
3.0/3.1/3.2 versions (0x300/0x310/0x320) for readable output.

The framing constants (CbSize, HcdCapabilities) stay strict — they
validate the PDU layout, not device data, so an invalid value there remains a
genuine decode error.

Encoding named values is byte-identical (the to_u32 results equal the previous
#[repr(u32)] discriminants), so this is transparent on the wire for existing
devices.

Verification

  • Round-trip tested in ironrdp-testsuite-core (new tests/rdpeusb/sink.rs): a
    USB 3.2 capability set decodes and round-trips; an unrecognized value decodes as
    Other.
  • Verified end-to-end against a real USB-3.2 flash drive redirected over a
    FreeRDP-with-urbdrc client: ADD_DEVICE now fully decodes and the device
    enumerates, where it previously errored out.

Breaking change

SupportedUsbVer / UsbdiVer / UsbBusIfaceVer / DeviceSpeed are no longer
#[repr(u32)] fieldless enums — each gains an Other(u32) variant (and
SupportedUsbVer gains Usb30/Usb31/Usb32). Downstream code matching these
exhaustively will need a wildcard arm. No other crate in this workspace is
affected.

Deliberately out of scope (happy to fold in if you'd prefer)

  • The client-side builder to_supported_usb_version still maps bcdUSB to at
    most Usb20; it could emit the new USB-3 versions so an announced device
    reports its true bcdUSB. Kept separate since this PR targets the decode path.
  • NoAckIsochWriteJitterBufferSizeInMs decode stays strict (0 or 10..=512 per
    spec) — only the version/speed enums are loosened here.

@uchouT

uchouT (uchouT) commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

I think there is a spec/codecs-layer question here.

MS-RDPEUSB 2.2.11 defines Supported_USB_Version as: “The value MUST be one of the following:” USB 1.0 0x100, USB 1.1 0x110, USB 2.0 0x200. So, strictly speaking, 0x300/0x320 are outside the documented PDU syntax.

But FreeRDP currently writes the raw USB device descriptor bcdUSB into this field in urbdrc_send_add_device(), so a USB 3.x device can indeed produce 0x300, 0x310, 0x320, etc. I’m not yet sure whether this is merely a FreeRDP bug or whether Microsoft’s server also accepts these values in practice. I’ll test this and follow up.

Relevant FreeRDP evidence:

If the test confirms that Microsoft accepts higher bcdUSB values here, then I agree we should relax this in the PDU codec for interoperability.

Fwiw, per ironrdp-pdu/README.md, for resilient parsing the preferred shape is to “consider providing a newtype struct wrapping the appropriate integer type”, rather than adding Other(u32) to an enum.

@clintcan

clintcan commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for digging into this — your FreeRDP trace matches exactly what we captured live, and it's the observation that motivated this PR: a real USB 3.2 flash drive (bcdUSB = 0x0320) redirected through FreeRDP's urbdrc/libusb client announces Supported_USB_Version = 0x320 on the wire. Before this change, the decode error propagated out of the channel processing and tore down the whole session for that device — a metadata field killing the channel.

On the spec text: agreed, 2.2.11 says "MUST be one of" 0x100/0x110/0x200 — but the structure reads as authored in the USB-2 era and never updated (DeviceIsHighSpeed models only full/high speed, no SuperSpeed either). Meanwhile the deployed client population, per your trace, writes raw bcdUSB into the field. So whichever way your Microsoft-server test lands, a decoder that hard-errors here rejects traffic real clients demonstrably send — resilient parsing (with the named values kept for logs) seems right for interop either way. That said, your test would settle whether Windows' own server is lenient or FreeRDP is emitting values Microsoft never accepts, and I'd be glad to fold the result into the PR description. (For completeness: our capture is FreeRDP-as-client only — the devices we've exercised over mstsc's RemoteFX USB redirection were all USB 2.0-era, so we have no mstsc datapoint for 0x300+.)

On the shape: fair point, and the README's dual-representation critique (Other(0x200) vs Usb20 representing the same wire value) applies to this PR as written. Happy to reshape the four fields into newtype structs with associated consts (the http::StatusCode pattern the README recommends) if the maintainers prefer — the diff is about the same size and round-trip encoding is preserved.

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Jumping in to express my preference clearly and give a small review.

First, I appreciate the spec-grounded and empirical investigation. Indeed, the spec does not always describe the reality; actually that’s common enough for RDP. FreeVNC typically does not deviate from the spec by mistake (although it may happen), I like to think of it as an accumulation of targeted workarounds based on what is observed in the real world and/or from reverse engineering to some extent. Always good to question, of course, but in this case, it makes sense: we’ve got USB V3 and variants for a while now.

Moving away from the variant Other fallback which can alias a named value, breaking round-trip identity and Eq, is a requirement on my side before we merge this PR (we are hunting down this kind of pattern across the database).

That’s pretty much the biggest issue here, otherwise everything is pretty neat!

Out of scope here, but it may also be worth evaluate things like the hard-fail in UsbDeviceCaps::check_device_speed (cc uchouT (@uchouT)).

It also sounds like the other out of scope actions mentioned in the PR body are worth considering and implementing in follow up PRs.

Comment on lines +16 to +37
/// A real USB 3.2 device reports `SupportedUsbVersion = 0x320`. This used to be
/// rejected during decode (tearing down the URBDRC channel); it must now decode
/// and round-trip.
#[test]
fn usb3_capabilities_round_trip() {
let original = caps(SupportedUsbVer::Usb32);
let encoded = encode_vec(&original).expect("encode should succeed");
let decoded: UsbDeviceCaps = decode(&encoded).expect("USB 3.2 capabilities should decode");
assert_eq!(decoded.supported_usb_ver, SupportedUsbVer::Usb32);
assert_eq!(original, decoded);
}

/// An unrecognized device-reported capability value is preserved verbatim in
/// `Other` rather than failing the decode.
#[test]
fn unknown_capability_value_decodes_as_other() {
let original = caps(SupportedUsbVer::Other(0x9999));
let encoded = encode_vec(&original).expect("encode should succeed");
let decoded: UsbDeviceCaps = decode(&encoded).expect("unknown version should decode as Other");
assert_eq!(decoded.supported_usb_ver, SupportedUsbVer::Other(0x9999));
assert_eq!(original, decoded);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nitpick: These tests assert round-trip, not the wire contract.
Both tests build a UsbDeviceCaps in Rust, encode it with the crate, then decode; so they lean on the crate's own encode to produce the bytes.
The reported bug is a decode failure of bytes a real device sends; a compensating encode bug would pass these. A decode-from-raw-&[u8] test (28 bytes with 0x320 at the SupportedUsbVersion offset) would reproduce the actual failure and also pin the field order/offset independently of encode.

suggestion: Also it may be possible to use rstest for fixture-based testing and deduplicate code. It would be easy to also include some of the other variants although we don’t need to be exhaustive at this point.

@uchouT

Copy link
Copy Markdown
Contributor

Out of scope here, but it may also be worth evaluate things like the hard-fail in UsbDeviceCaps::check_device_speed (cc uchouT (uchouT (@uchouT))).

It also sounds like the other out of scope actions mentioned in the PR body are worth considering and implementing in follow up PRs.

Exactly. I haven’t fully reviewed the TS_URB codec yet, so more similar issues may surface once RDPEUSB gets exercised by real integrations (macrdp, qemu-rdp, etc).

I’m currently working on the ironrdp-server RDPEUSB integration, and I expect qemu-rdp to eventually run on top of it. So I’m glad clintcan is uncovering these issues before we run into them deeper in qemu-rdp development :)

…values

`UsbDeviceCaps::decode` rejected any value it did not name for the four
device-reported capability fields of USB_DEVICE_CAPABILITIES
(UsbBusInterfaceVersion, USBDI_Version, SupportedUsbVersion, DeviceIsHighSpeed).
A real USB 3.2 device reports SupportedUsbVersion = 0x320, which was not named,
so decoding its ADD_DEVICE failed and tore down the URBDRC channel.

Model the four fields as newtype structs wrapping the raw `u32` (the
`http::StatusCode` shape the crate's README recommends): named associated
constants cover the documented values (SupportedUsbVer also names USB
3.0/3.1/3.2), while any other device-reported value is preserved verbatim
instead of failing the decode. Unlike an `Other(u32)` enum fallback, a newtype
cannot alias a named value, so round-trip identity and `Eq` stay exact. The
framing constants (CbSize, HcdCapabilities) stay strict — they validate the PDU
layout, not device data.

The added tests decode a real 0x320 device's raw 28-byte USB_DEVICE_CAPABILITIES
directly (independent of this crate's own encode, and pinning the field offset)
and round-trip every named version plus an unnamed value.

BREAKING CHANGE: SupportedUsbVer/UsbdiVer/UsbBusIfaceVer/DeviceSpeed are now
newtype structs over u32 with associated constants (e.g. SupportedUsbVer::USB_20,
DeviceSpeed::HIGH_SPEED) instead of fieldless enums; construct an arbitrary
value with `from_u32` and read the wire value with `to_u32`.
@clintcan clintcan force-pushed the feat/rdpeusb-lenient-caps branch from c38037b to 80c7c8b Compare July 9, 2026 19:10
@clintcan

clintcan commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Reshaped in 80c7c8b9. Both points addressed:

  • Dropped the Other fallback. SupportedUsbVer/UsbdiVer/UsbBusIfaceVer/DeviceSpeed are now newtype structs over u32 with named associated consts (SupportedUsbVer::USB_20, DeviceSpeed::HIGH_SPEED, …) — the http::StatusCode shape the README recommends. A newtype can't alias a named value, so round-trip identity and Eq are exact by construction; an unnamed device value is just the wrapped u32. from_u32/to_u32 construct/read the raw value.
  • Added a decode-from-raw-bytes test. usb3_capabilities_decode_from_raw_bytes decodes a hand-authored 28-byte USB_DEVICE_CAPABILITIES with 0x320 at the Supported_USB_Version offset (12) — reproducing the actual decode failure independent of this crate's encode, and pinning the field offset. The round-trip test is now an rstest fixture over every named version plus an unnamed value.

On the spec datapoint: still glad to fold in your Microsoft-server result whenever you get to it, but as discussed it's decoupled from the merge now — the decoder tolerates whatever the device reports either way.

The out-of-scope items (the check_device_speed hard-fail, other TS_URB codec edges) I'm happy to take as follow-up PRs — and agreed they'll keep surfacing as macrdp / qemu-rdp exercise the server direction.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants