Releases: steck0714/Mock-webusb
Release list
v0.0.3b
Summary of Implementation
frame_origin.py(New Module) —FrameOriginTracker- The Python side (the completely zero-trust side that does not trust JS at all) traverses the frame tree using
page.mainFrame()/QWebEngineFrame.children(). - It distributes an unguessable token to each frame via
runJavaScript(), ensuring it is delivered only to that specific frame. - Other frames cannot read this token due to the Same-Origin Policy.
- Retraversal is triggered by the
navigationRequestedsignal (discovered in0.0.3a0), with a periodic 2-second interval scan running in parallel as a safeguard.
- The Python side (the completely zero-trust side that does not trust JS at all) traverses the frame tree using
- Token Integration Across Slots
- Added a
frame_tokenparameter to all 16@Slotmethods (ranging fromlistDevicesto various transfer-related methods). - Routed the token through
_get_open_device(), a bottleneck method that almost all other methods pass through. - This fully operationalized the feature, ensuring that "a handle opened by a subframe can continuously and exclusively be used by that subframe."
- Added a
- Core Design Rule
- When wiring the tracker, origins are resolved strictly via tokens without exception, including the main frame.
- We intentionally left no loopholes like "an empty token defaults to the main frame."
- Allowing such a loophole would let an adversarial subframe impersonate the main frame simply by invoking the raw
QWebChanneldirectly with an empty token.
Pitfalls & Fixes
Verified on an actual QWebEnginePage, discovering and resolving three implementation pitfalls:
runJavaScript()Error- Passing only the code string as a single argument throws a "not enough arguments" error. A callback function is strictly required.
setHtml()URL BehaviorsetHtml(html, baseUrl=...)fails to reflect thebaseUrlinQWebEngineFrame.url(), treating it as adata:URL instead. Usingpage.load()works correctly.
QWebEngineFrameIdentity Issue- The
QWebEngineFrameobject returns a brand-new wrapper every timechildren()is called. - This makes frame identification via
id()completely unreliable. We removed the dependency on unique IDs and resolved this by issuing fresh tokens on every scan while enforcing an upper limit on the total token count.
- The
Testing
test_frame_origin.py(New, 9 test cases)- Includes 8 fast tests using mocks.
- Includes 1 integration test using a real
QWebEnginePageand cross-origin iframes.
test_bridge.pyUpdates (2 test cases added)- Added tests for token spoofing rejection and handle isolation between origins.
- Legacy Test Maintenance
- Updated existing Python/JS tests to support the new signature (updating 16 instances of the
fakeBridgemock).
- Updated existing Python/JS tests to support the new signature (updating 16 instances of the
Note: Since we cannot guarantee stable operation under all conditions yet, please wait for the next version release.
v0.0.3a
What was implemented in this update:
・Created errors.py: Centralized DOMException prefix strings (e.g., "SecurityError: ...") into a single function. Previously, these were hardcoded in over 10 different places within bridge.py. This change eliminates a hard-to-find bug where a typo (e.g., "SecurtyError:") wouldn't trigger a syntax error, causing the JavaScript side to silently fall back to an incorrect error name. Dedicated tests (test_errors.py) have also been added.
・PyUSB Version: Confirmed in the previous update that the latest version (1.3.1) is being used. No changes were made here.
・Current Status: Please note that this version may still not be fully functional. Kindly wait for the next release.
v0.0.3
v0.0.2b
New Changes / Updates
Fixed a security vulnerability where cross-origin iframes could hijack top-level page USB permissions.
Previously, install() was configured with script.setRunsOnSubFrames(True), executing the polyfill across all iframes. However, WebUSBBridge._current_origin() relies on QWebEnginePage.url() to determine the origin. Since this always returns the top-level frame's URL, PySide6's public API provides no way to distinguish which specific subframe a request came from via QWebChannel.As a result, untrusted cross-origin iframes (such as ads or third-party embeds) could invoke navigator.usb.requestDevice() or getDevices(), and the request would be treated as originating from the top-level page itself. This allowed malicious iframes to spoof the origin and access any USB device previously authorized by the top-level page, completely breaking the origin isolation model.Since there is currently no reliable way to pinpoint the exact origin per frame, we have taken the safer approach and changed the setting to setRunsOnSubFrames(False) (which prevents navigator.usb from being defined inside iframes). While a real Chrome environment supports WebUSB inside cross-origin iframes with proper isolation, we decided to prioritize security over full functionality since we cannot properly validate it.We also found that install() had zero test coverage. We have newly created tests/test_install.py (utilizing real QWebEngineScript and QWebChannel). We confirmed that reverting this fix causes the newly added tests to fail.
Other 3 fixes discovered (after re-verifying Chrome's usb_device.cc):open() (JS) lacked idempotency, causing Python-side handle leaks when called twice.selectAlternateInterface() completely lacked claim requirement checks.
When a configuration was unselected, claimInterface/releaseInterface incorrectly displayed "Protected Class" as the reason.
Regression tests have been added for all of the above, and we verified they fail if the fixes are reverted.
Current Status & Disclaimer
We are actively looking into a way to properly identify subframe origins.
For now, we are keeping setRunsOnSubFrames(False) for security reasons, but we are working hard to restore setRunsOnSubFrames(True) as soon as a safe method is found.Due to these underlying issues, we cannot guarantee full functionality at this moment.
If you require stable iframe support, please wait for the upcoming version.
v0.0.2a
Changes in this version
Lack of idempotency in open() (JS)In actual Chrome, if a device is already open, it resolves immediately with success.
Our previous implementation called openDevice() every time.
As a result, calling open() twice caused the Python side to keep issuing new handles.
This left the first handle (which included claimed interface information) unclosed, causing a leak.Complete lack of EnsureInterfaceClaimed() equivalent validation in selectAlternateInterface()It was possible to change the alternate setting of any interface without ever calling claimInterface() (meaning it bypassed the protected class checks).
This has been fixed to reject with an InvalidStateError, matching actual Chrome behavior.Lack of explicit EnsureDeviceConfigured() equivalent checks in claimInterface and releaseInterfaceWhen no configuration was selected, the requests were eventually rejected through the interface_class_for failsafe. However, it threw an incorrect error message blaming a "protected class".
This has been corrected to explicitly return InvalidStateError: "the device must have a configuration selected", just like actual Chrome. (Verified: confirmed that reverting this fix actually produces the incorrect SecurityError message).That being said, we still cannot guarantee it will work flawlessly.
Please wait for the next version.
v0.0.2
Changes in this Update
After reviewing the actual Chromium Blink implementation (third_party/blink/renderer/modules/webusb/usb_device.cc), I confirmed that transferIn, transferOut, and clearHalt completely lacked checks equivalent to EnsureEndpointAvailable() found in the production version of Chrome.
To address this, I extended the verification check ("whether the target interface is claimed"), which was previously added only to controlTransferIn/Out, to bulk/interrupt transfers and clearHalt.
This effectively plugs the loophole that allowed direct bulk/interrupt reading and writing to protected class endpoints without going through controlTransfer.
I have also added two regression tests and confirmed that they fail as expected when the fixes are reverted.
Isochronous Transfers (Implemented but Untested on Physical Devices)Although not available in the public pyusb API, I implemented this feature using a workaround that accesses the iso_read/iso_write methods (private internal APIs) of the libusb1 backend via dev._ctx.handle.It only supports uniform packet lengths due to a limitation within the pyusb backend itself.It safely falls back to a NotSupportedError in environments where the backend cannot be used.Since the AI sandbox lacks physical USB hardware, the isochronous transfers themselves could not be verified on an actual device.
However, I have successfully tested the validation logic, packet-splitting arithmetic, and the success paths using a fake backend.
For this reason, it is explicitly marked as "untested" in the README and CHANGELOG.
Therefore, we cannot guarantee that it will work perfectly; please wait for the next version.
v0.0.1b
v0.0.1a
Bug Fixes & UpdatesbulkTransferIn:
Fixed a missing IN direction bit (| 0x80), which caused real-device IN transfers to always fail.
requestDeviceChooser: Added a missing re-entrancy guard to prevent duplicate dialog instances in nested event loops.
controlTransferIn/Out: Fixed a critical bug where the protected class validation in claimInterface could be completely bypassed.
(Most Critical)interface_class_for: Fixed a fail-open vulnerability that erroneously identified non-existent interface numbers as "safe."
Added Hub (0x09) to the list of protected interface classes.Implemented USBConfiguration.configurationName and USBAlternateInterface.interfaceName.
Fixed an issue where Control-type endpoints were not being properly excluded.
v0.0.1
This version briefly explains what was changed.
The requestDeviceChooser method did not exist on the meta-object at all, meaning it did not appear as a method on the JavaScript-side proxy generated by QWebChannel. Meanwhile, _enumerate_filtered_devices was registered with a mismatch between its actual arguments (the usb_core/usb_util pyusb modules and the filters/exclusion_filters lists) and its @Slot(str, result=str) declaration. Because test_bridge.py consistently calls bridge.requestDeviceChooser(...) directly from Python, it always passes regardless of whether the @Slot decorator is present or where it is applied. In fact, even with the bug present, all 24 existing tests passed. This is the exact same blind spot as the previously fixed bug where the @Slot decorator for requestDeviceChooser was entirely missing.
To address this, I added test_requestDeviceChooser_is_registered_as_qt_slot to verify the method by inspecting staticMetaObject and QMetaMethod directly. I have also confirmed that this test successfully fails against a separate copy of the codebase replicating the bug.
However, since this still does not fully guarantee reliable operation, users who prefer a more stable version should wait for the next release.