Overview

Medium
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in HID
DescriptionIncorrect authorization in HID
ComponentHID
Sub-componentHID
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517802696
Fix commit397b9463bd35 (chromium/src) +118/-4
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-08-27 (public git)
Disclosed2026-09-22
Patch gap26 days — fix public in source before the advisory

Background

WebView guest
An embedded <webview> (or Controlled Frame) whose content runs in a separate frame tree owned by an embedder page, tracked by extensions::WebViewGuest.
Embedder origin
The origin of the top-level page hosting a guest, used to partition per-embedder permission storage via WebViewChooserContext.
HidChooserContext
The profile-level store of WebHID device permission grants, consulted when a frame is treated as an ordinary top-level page rather than a guest.
Unattached guest
A guest that exists but has no embedder_rfh() yet, for example a window.open() child that the embedder has not attached.

Root Cause Analysis

GetWebViewEmbedderOrigin() in chrome_hid_delegate.cc returned std::nullopt in two distinct situations: when the frame is not a <webview> guest at all, and when the frame is a guest whose embedder_rfh() is currently null because it is not attached. Callers such as ChromeHidDelegate interpret std::nullopt uniformly as “this is not a webview,” so they consult the profile-level HidChooserContext instead of the per-embedder WebViewChooserContext. This violated the invariant that every webview guest must route its permission checks through partitioned WebViewChooserContext storage, letting an unattached guest observe and revoke profile-level HID grants belonging to its origin.

The fix keeps returning std::nullopt only for genuine non-guest frames, but returns an opaque url::Origin() for a guest that has no attached embedder, so the optional is always engaged for guests. With an engaged (opaque) origin, callers route through WebViewChooserContext, which holds no grants for an unknown embedder, correctly denying HasDevicePermission and preventing RevokeDevicePermission from touching profile-level state.

Key insight
The single mistake was conflating “not a guest” with “guest not yet attached” by returning std::nullopt for both, which silently downgraded an unattached guest to top-level profile permission storage; the fix disambiguates by returning an opaque origin for unattached guests so the optional is always engaged and callers stay on the partitioned WebViewChooserContext path.

Attack Path

  1. Establish a guest context An embedder page hosts a <webview>/Controlled Frame guest whose origin already has a profile-level WebHID grant recorded in HidChooserContext.
  2. Create an unattached child The guest calls window.open() to spawn a new window that the embedder has not yet attached, so its embedder_rfh() is null. (inferred)
  3. Trigger the misrouted lookup WebHID calls in the unattached child reach GetWebViewEmbedderOrigin(), which returns std::nullopt, causing ChromeHidDelegate to consult profile-level HidChooserContext instead of WebViewChooserContext.
  4. Observe or revoke profile grants Because it is treated as a top-level page, the unattached guest can see profile-level HID permission state for its origin and invoke RevokeDevicePermission against grants it should not control. (inferred)

Impact Assessment

An attacker controlling an unattached webview guest gains incorrect authorization over WebHID permissions in the browser process, able to observe and revoke profile-level HID device grants for its origin rather than being confined to the empty per-embedder WebViewChooserContext. The preconditions are that WebHID-in-webview is enabled and an unattached guest (such as a window.open() child) can be created for an origin with existing profile-level grants. The advisory classifies this as incorrect authorization in HID (LogicError) and reports no exploited-in-the-wild status; any escalation beyond permission observation/revocation is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/hid/chrome_hid_delegate.cc
modified
ControlledFrameNewWindowHidBrowserTest
chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
modified

Files Changed

  • chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
  • chrome/browser/hid/chrome_hid_delegate.cc

Audit Directions

  • Optional-as-signal overloading
    Audit functions that return std::optional where std::nullopt encodes two different meanings (absence vs. an unknown/transient state), since callers often collapse them into one branch that skips partitioned checks.
  • Guest attachment races
    Review permission and storage lookups that depend on embedder_rfh() or other attachment state, checking behavior for unattached or in-transit guests created via window.open().
  • Partitioned vs. profile storage routing
    Inspect other device-permission delegates (USB, Serial, Bluetooth) for the same pattern of falling back to profile-level chooser contexts when embedder origin resolution fails for a webview guest.
From 397b9463bd35f9a854d917daf48b048cf69678a5 Mon Sep 17 00:00:00 2001
From: Rob Pitkin <[email protected]>
Date: Thu, 27 Aug 2026 09:20:39 -0700
Subject: [PATCH] hid: Route unattached webview guests via WebViewChooserContext

GetWebViewEmbedderOrigin() returned std::nullopt both for frames that
are not <webview> guests and for guests whose embedder_rfh() is null
because the guest is not currently attached (for example a window.open()
child that the embedder has not yet attached). Callers treat nullopt as
"not a webview" and consult the profile-level HidChooserContext state
instead of the per-embedder WebViewChooserContext, so an unattached
guest could observe and revoke profile-level grants for its origin.

Return an opaque origin instead of nullopt for unattached guests so that
the optional is engaged for every webview guest and callers always route
through WebViewChooserContext, which holds no grants for an unknown
embedder.

Add a Controlled Frame browser test that opens a new window from a
guest, leaves it unattached, and verifies that ChromeHidDelegate denies
HasDevicePermission and that RevokeDevicePermission does not affect the
profile-level grant.

Bug: 517802696
Change-Id: Id199dac6afc596b16d06b9c80ca29c6283e00d97
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8269296
Reviewed-by: Simon Hangl <[email protected]>
Reviewed-by: Matt Reynolds <[email protected]>
Commit-Queue: Rob Pitkin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1687302}
---
diff --git a/chrome/browser/hid/chrome_hid_delegate.cc b/chrome/browser/hid/chrome_hid_delegate.cc
index 2fb46de..e296d150 100644
--- a/chrome/browser/hid/chrome_hid_delegate.cc
+++ b/chrome/browser/hid/chrome_hid_delegate.cc
@@ -25,6 +25,7 @@
 #include "extensions/buildflags/buildflags.h"
 #include "mojo/public/cpp/bindings/callback_helpers.h"
 #include "services/device/public/mojom/hid.mojom-forward.h"
+#include "url/origin.h"
 
 #if BUILDFLAG(IS_ANDROID)
 #include "chrome/browser/ui/android/device_dialog/hid_chooser_dialog_android.h"
@@ -57,6 +58,9 @@
 }
 #endif  // !BUILDFLAG(IS_ANDROID)
 
+// Returns the embedder origin if `render_frame_host` is a <webview> guest, or
+// std::nullopt if it is not a guest context. Unattached guests return an opaque
+// origin to ensure callers use partitioned permission storage.
 std::optional<url::Origin> GetWebViewEmbedderOrigin(
     content::RenderFrameHost* render_frame_host) {
   if (!render_frame_host) {
@@ -66,11 +70,14 @@
 #if BUILDFLAG(ENABLE_EXTENSIONS)
   if (auto* web_view =
           extensions::WebViewGuest::FromRenderFrameHost(render_frame_host)) {
-    auto* embedder_rfh = web_view->embedder_rfh();
-    if (!embedder_rfh) {
-      return std::nullopt;
+    if (auto* embedder_rfh = web_view->embedder_rfh()) {
+      return embedder_rfh->GetMainFrame()->GetLastCommittedOrigin();
     }
-    return embedder_rfh->GetMainFrame()->GetLastCommittedOrigin();
+    // The guest exists but isn't currently attached to its embedder. Return an
+    // opaque origin so that callers still consult `WebViewChooserContext`
+    // (which will hold no grants for it) instead of treating the frame as a
+    // top-level page and falling through to profile-level state.
+    return url::Origin();
   }
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS)
 
diff --git a/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
index 8434f53..8a36d4e 100644
--- a/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
@@ -5,9 +5,22 @@
 #include <optional>
 #include <string>
 
+#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.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/profiles/profile.h"
+#include "components/guest_view/browser/guest_view_base.h"
+#include "components/guest_view/browser/guest_view_manager_delegate.h"
+#include "components/guest_view/browser/test_guest_view_manager.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/api/extensions_api_client.h"
+#include "extensions/browser/guest_view/web_view/web_view_guest.h"
+#include "extensions/common/extension_features.h"
+#include "services/device/public/cpp/test/fake_hid_manager.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
@@ -238,4 +251,98 @@
   EXPECT_EQ("SUCCESS", content::EvalJs(app_frame, test_script));
 }
 
+class ControlledFrameNewWindowHidBrowserTest
+    : public ControlledFrameNewWindowBrowserTest {
+ public:
+  void SetUpOnMainThread() override {
+    ControlledFrameNewWindowBrowserTest::SetUpOnMainThread();
+
+    guest_view_manager_ = factory_.GetOrCreateTestGuestViewManager(
+        profile(), extensions::ExtensionsAPIClient::Get()
+                       ->CreateGuestViewManagerDelegate());
+
+    mojo::PendingRemote<device::mojom::HidManager> pending_remote;
+    hid_manager_.Bind(pending_remote.InitWithNewPipeAndPassReceiver());
+    base::test::TestFuture<std::vector<device::mojom::HidDeviceInfoPtr>>
+        devices_future;
+    auto* chooser_context = HidChooserContextFactory::GetForProfile(profile());
+    chooser_context->SetHidManagerForTesting(std::move(pending_remote),
+                                             devices_future.GetCallback());
+    ASSERT_TRUE(devices_future.Wait());
+  }
+
+  void TearDownOnMainThread() override {
+    guest_view_manager_ = nullptr;
+    ControlledFrameNewWindowBrowserTest::TearDownOnMainThread();
+  }
+
+  guest_view::TestGuestViewManager* guest_view_manager() {
+    return guest_view_manager_;
+  }
+
+  device::FakeHidManager& hid_manager() { return hid_manager_; }
+
+ private:
+  base::test::ScopedFeatureList scoped_feature_list_{
+      extensions_features::kEnableWebHidInWebView};
+  guest_view::TestGuestViewManagerFactory factory_;
+  raw_ptr<guest_view::TestGuestViewManager> guest_view_manager_ = nullptr;
+  device::FakeHidManager hid_manager_;
+};
+
+IN_PROC_BROWSER_TEST_F(ControlledFrameNewWindowHidBrowserTest,
+                       UnattachedGuestUsesWebViewPermissionStore) {
+  auto [app_frame, controlled_frame] =
+      InstallAndOpenIwaThenCreateControlledFrame(
+          /*controlled_frame_host_name=*/std::nullopt,
+          "/controlled_frame.html");
+  ASSERT_TRUE(app_frame);
+  ASSERT_TRUE(controlled_frame);
+
+  // Connect a fake device and grant the guest's origin profile-level access to
+  // it, as if the same origin had been granted access from a top-level tab.
+  device::mojom::HidDeviceInfoPtr device = hid_manager().CreateAndAddDevice(
+      "physical-id", /*vendor_id=*/0x1234, /*product_id=*/0xabcd,
+      "Test HID Device", "serial", device::mojom::HidBusType::kHIDBusTypeUSB);
+  auto* chooser_context = HidChooserContextFactory::GetForProfile(profile());
+  const url::Origin guest_origin = controlled_frame->GetLastCommittedOrigin();
+  chooser_context->GrantDevicePermission(guest_origin, *device);
+  ASSERT_TRUE(chooser_context->HasDevicePermission(guest_origin, *device));
+
+  // Open a new window from the guest and leave it unattached.
+  ASSERT_TRUE(content::ExecJs(app_frame, R"(
+    const frame = document.getElementsByTagName('controlledframe')[0];
+    frame.addEventListener('newwindow', (e) => {
+      e.preventDefault();
+      window.pendingNewWindow = e.window;
+    });
+    frame.executeScript({code: 'window.open();'});
+  )"));
+  guest_view_manager()->WaitForNumGuestsCreated(2u);
+
+  guest_view::GuestViewBase* new_guest =
+      guest_view_manager()->GetLastGuestViewCreated();
+  ASSERT_TRUE(new_guest);
+  ASSERT_FALSE(new_guest->attached());
+  content::RenderFrameHost* new_guest_rfh = new_guest->GetGuestMainFrame();
+  ASSERT_TRUE(new_guest_rfh);
+  ASSERT_TRUE(extensions::WebViewGuest::FromRenderFrameHost(new_guest_rfh));
+  ASSERT_EQ(guest_origin, new_guest_rfh->GetLastCommittedOrigin());
+
+  // Permission checks for guests must be answered from the per-embedder
+  // WebViewChooserContext rather than the profile-level store, so neither the
+  // attached opener nor the unattached new window should be granted access.
+  ChromeHidDelegate hid_delegate;
+  EXPECT_FALSE(hid_delegate.HasDevicePermission(profile(), controlled_frame,
+                                                guest_origin, *device));
+  EXPECT_FALSE(hid_delegate.HasDevicePermission(profile(), new_guest_rfh,
+                                                guest_origin, *device));
+
+  // Revoking on behalf of the unattached guest must not touch the
+  // profile-level grant.
+  hid_delegate.RevokeDevicePermission(profile(), new_guest_rfh, guest_origin,
+                                      *device);
+  EXPECT_TRUE(chooser_context->HasDevicePermission(guest_origin, *device));
+}
+
 }  // namespace controlled_frame
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
index 8434f53..8a36d4e 100644
--- a/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_new_window_browsertest.cc
@@ -5,9 +5,22 @@
 #include <optional>
 #include <string>
 
+#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.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/profiles/profile.h"
+#include "components/guest_view/browser/guest_view_base.h"
+#include "components/guest_view/browser/guest_view_manager_delegate.h"
+#include "components/guest_view/browser/test_guest_view_manager.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/api/extensions_api_client.h"
+#include "extensions/browser/guest_view/web_view/web_view_guest.h"
+#include "extensions/common/extension_features.h"
+#include "services/device/public/cpp/test/fake_hid_manager.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
@@ -238,4 +251,98 @@
   EXPECT_EQ("SUCCESS", content::EvalJs(app_frame, test_script));
 }
 
+class ControlledFrameNewWindowHidBrowserTest
+    : public ControlledFrameNewWindowBrowserTest {
+ public:
+  void SetUpOnMainThread() override {
+    ControlledFrameNewWindowBrowserTest::SetUpOnMainThread();
+
+    guest_view_manager_ = factory_.GetOrCreateTestGuestViewManager(
+        profile(), extensions::ExtensionsAPIClient::Get()
+                       ->CreateGuestViewManagerDelegate());
+
+    mojo::PendingRemote<device::mojom::HidManager> pending_remote;
+    hid_manager_.Bind(pending_remote.InitWithNewPipeAndPassReceiver());
+    base::test::TestFuture<std::vector<device::mojom::HidDeviceInfoPtr>>
+        devices_future;
+    auto* chooser_context = HidChooserContextFactory::GetForProfile(profile());
+    chooser_context->SetHidManagerForTesting(std::move(pending_remote),
+                                             devices_future.GetCallback());
+    ASSERT_TRUE(devices_future.Wait());
+  }
+
+  void TearDownOnMainThread() override {
+    guest_view_manager_ = nullptr;
+    ControlledFrameNewWindowBrowserTest::TearDownOnMainThread();
+  }
+
+  guest_view::TestGuestViewManager* guest_view_manager() {
+    return guest_view_manager_;
+  }
+
+  device::FakeHidManager& hid_manager() { return hid_manager_; }
+
+ private:
+  base::test::ScopedFeatureList scoped_feature_list_{
+      extensions_features::kEnableWebHidInWebView};
+  guest_view::TestGuestViewManagerFactory factory_;
+  raw_ptr<guest_view::TestGuestViewManager> guest_view_manager_ = nullptr;
+  device::FakeHidManager hid_manager_;
+};
+
+IN_PROC_BROWSER_TEST_F(ControlledFrameNewWindowHidBrowserTest,
+                       UnattachedGuestUsesWebViewPermissionStore) {
+  auto [app_frame, controlled_frame] =
+      InstallAndOpenIwaThenCreateControlledFrame(
+          /*controlled_frame_host_name=*/std::nullopt,
+          "/controlled_frame.html");
+  ASSERT_TRUE(app_frame);
+  ASSERT_TRUE(controlled_frame);
+
+  // Connect a fake device and grant the guest's origin profile-level access to
+  // it, as if the same origin had been granted access from a top-level tab.
+  device::mojom::HidDeviceInfoPtr device = hid_manager().CreateAndAddDevice(
+      "physical-id", /*vendor_id=*/0x1234, /*product_id=*/0xabcd,
+      "Test HID Device", "serial", device::mojom::HidBusType::kHIDBusTypeUSB);
+  auto* chooser_context = HidChooserContextFactory::GetForProfile(profile());
+  const url::Origin guest_origin = controlled_frame->GetLastCommittedOrigin();
+  chooser_context->GrantDevicePermission(guest_origin, *device);
+  ASSERT_TRUE(chooser_context->HasDevicePermission(guest_origin, *device));
+
+  // Open a new window from the guest and leave it unattached.
+  ASSERT_TRUE(content::ExecJs(app_frame, R"(
+    const frame = document.getElementsByTagName('controlledframe')[0];
+    frame.addEventListener('newwindow', (e) => {
+      e.preventDefault();
+      window.pendingNewWindow = e.window;
+    });
+    frame.executeScript({code: 'window.open();'});
+  )"));
+  guest_view_manager()->WaitForNumGuestsCreated(2u);
+
+  guest_view::GuestViewBase* new_guest =
+      guest_view_manager()->GetLastGuestViewCreated();
+  ASSERT_TRUE(new_guest);
+  ASSERT_FALSE(new_guest->attached());
+  content::RenderFrameHost* new_guest_rfh = new_guest->GetGuestMainFrame();
+  ASSERT_TRUE(new_guest_rfh);
+  ASSERT_TRUE(extensions::WebViewGuest::FromRenderFrameHost(new_guest_rfh));
+  ASSERT_EQ(guest_origin, new_guest_rfh->GetLastCommittedOrigin());
+
+  // Permission checks for guests must be answered from the per-embedder
+  // WebViewChooserContext rather than the profile-level store, so neither the
+  // attached opener nor the unattached new window should be granted access.
+  ChromeHidDelegate hid_delegate;
+  EXPECT_FALSE(hid_delegate.HasDevicePermission(profile(), controlled_frame,
+                                                guest_origin, *device));
+  EXPECT_FALSE(hid_delegate.HasDevicePermission(profile(), new_guest_rfh,
+                                                guest_origin, *device));
+
+  // Revoking on behalf of the unattached guest must not touch the
+  // profile-level grant.
+  hid_delegate.RevokeDevicePermission(profile(), new_guest_rfh, guest_origin,
+                                      *device);
+  EXPECT_TRUE(chooser_context->HasDevicePermission(guest_origin, *device));
+}
+
 }  // namespace controlled_frame
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of WebView HID permission isolation during guest attachment window

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in ChromeHidDelegate allows a <webview> guest to bypass WebHID permission partitioning during its attachment window. When a guest’s embedder_rfh is unavailable, GetWebViewEmbedderOrigin incorrectly falls back to std::nullopt, treating the guest as a top-level context. This allows a compromised guest renderer to access WebHID permissions granted to its origin in the main user profile.

Affected files:

  • chrome/browser/hid/chrome_hid_delegate.cc
  • chrome/browser/hid/hid_chooser_context.cc

Estimated timestamp from git blame: 2024-02-23

Summary

A logical permission-bypass vulnerability exists in the WebHID guest isolation mechanism. During the asynchronous attachment window of a WebViewGuest, GetWebViewEmbedderOrigin incorrectly returns std::nullopt (the same value used for non-guest, top-level contexts). When WebHID permission checks or revocations are issued via Mojo IPC during this window, HidChooserContext falls back to checking the main profile-wide grant store instead of restricting the query to the partitioned WebViewChooserContext. This allows a compromised guest renderer to gain unauthorized access to, or revoke, WebHID permissions that the user previously granted to that origin in a top-level profile tab.

Root Cause Analysis

In chrome/browser/hid/chrome_hid_delegate.cc, the helper function GetWebViewEmbedderOrigin distinguishes a guest context from a non-guest context by returning std::optional<url::Origin>:

std::optional<url::Origin> GetWebViewEmbedderOrigin(
    content::RenderFrameHost* render_frame_host) {
  if (!render_frame_host) {
    return std::nullopt;
  }

#if BUILDFLAG(ENABLE_EXTENSIONS)
  if (auto* web_view =
          extensions::WebViewGuest::FromRenderFrameHost(render_frame_host)) {
    auto* embedder_rfh = web_view->embedder_rfh();
    if (!embedder_rfh) {
      return std::nullopt; // Fails open; indistinguishable from "not a webview"
    }
    return embedder_rfh->GetMainFrame()->GetLastCommittedOrigin();
  }
#endif  // BUILDFLAG(ENABLE_EXTENSIONS)

  return std::nullopt;
}

However, embedder_rfh() is defined in components/guest_view/browser/guest_view_base.h as:

content::RenderFrameHost* embedder_rfh() const {
  return attached() ? owner_rfh() : nullptr;
}

When a guest is in the process of attaching (attach_in_progress_ == true), attached() evaluates to false (per components/guest_view/browser/guest_view_base.h:211-214), meaning embedder_rfh() returns nullptr.

In WebViewGuest::SignalWhenReady, the finalization of the attachment is handled asynchronously while content scripts are being updated:

void WebViewGuest::SignalWhenReady(base::OnceClosure callback) {
  auto* manager = WebViewContentScriptManager::Get(browser_context());
  manager->SignalOnScriptsUpdated(std::move(callback));
}

This creates a timing window where the guest’s WebContents is already executing scripts and can communicate via Mojo, but attached() is still false.

Because GetWebViewEmbedderOrigin returns std::nullopt when embedder_rfh is null, HidChooserContext::HasDevicePermission processes the request as a non-guest context:

if (embedding_origin_of_web_view) {
  return web_view_chooser_context_.HasDevicePermission(...);
}

// Fallback to profile-wide ephemeral and persistent grants:
auto it = ephemeral_devices_.find(origin);
...

As a result, the partitioned web_view_chooser_context_ is bypassed, and the query falls back to checking the main profile-wide grants.

Potential Steps to Reproduce

Note: These are potential steps as our tooling does not have the capability to run code directly.

  1. Establish a top-level user profile where the origin https://example.com has been granted WebHID device access (e.g., in a normal browser tab).
  2. Launch an Isolated Web App (IWA) or Chrome App that embeds a <webview> or <controlledframe> hosting https://example.com.
  3. Have the app trigger an asynchronous operation (such as registering content scripts) to stall/delay the guest attachment callbacks.
  4. Within the guest renderer process (assuming compromise), immediately bind the blink::mojom::HidService Mojo pipe as soon as the interface broker is available.
  5. Issue a WebHID permission query (such as calling GetDevices or Connect) during the active attachment window.
  6. Observe that the browser process evaluates the permission using the main profile-wide grants instead of the partitioned WebViewChooserContext, granting the guest unauthorized access to the device.

Impact

This is a logical permission bypass of WebView/Controlled Frame isolation. A compromised guest renderer can access WebHID devices that were previously granted to its origin in top-level tabs, or can revoke those top-level permissions. It does not permit cross-origin access (the origin check still applies to the guest’s own origin), nor does it lead to sandbox escape or remote code execution.

Suggested Fix

Instead of failing open and returning std::nullopt when a guest’s embedder cannot be verified, GetWebViewEmbedderOrigin should explicitly distinguish between a non-guest context and an unattached guest context.

For example, we can return a std::optional<std::optional<url::Origin>> (where the outer optional indicates if it’s a guest context, and the inner optional holds the embedder origin), or explicitly check if extensions::WebViewGuest::FromRenderFrameHost is non-null and deny permission checks/revocations if embedder_rfh() is null.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker