Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in HID
DescriptionUse after free in HID
ComponentHID
Sub-componentHID
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker555299641
Fix commit78d5d3a255fb (chromium/src) +186/-1
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedWinD39 - Huynh Dinh Vu
Reported2026-09-01 (per vendor advisory)
Fix landed2026-09-16 (public git)
Disclosed2026-09-22
Patch gap6 days — fix public in source before the advisory

Background

WebHID
the Chromium subsystem that lets web content request access to Human Interface Devices, surfacing a device-picker dialog the user must confirm.
`HidChooser`
a content object owned per-request that holds the chooser dialog’s close closure via SetCloseClosure(); it is destroyed when its owning frame/document goes away.
`ForSecurityDropFullscreen()`
a call made when opening a device chooser that notifies the embedder’s WebContentsDelegate (ExitFullscreenModeForTab()) to leave fullscreen, which for a <controlledframe>/<webview> guest can run embedder script synchronously.
`base::ScopedClosureRunner`
a RAII wrapper that owns a base::OnceClosure and runs it on destruction unless Release() is called to hand ownership off.

Root Cause Analysis

In chrome_hid_delegate.cc, the chooser was created by directly feeding the return value of chrome::ShowDeviceChooserDialog() into chooser->SetCloseClosure(...). During ShowDeviceChooserDialog(), ForSecurityDropFullscreen() synchronously drives the embedder WebContentsDelegate out of fullscreen, and for a <controlledframe>/<webview> guest the embedder can respond by synchronously destroying the guest document, which frees the HidChooser. The invariant violated is that chooser was assumed to remain live across the call, but the pointer became dangling before SetCloseClosure() executed, producing a heap use-after-free write on return.

The fix stores the returned closure in a local base::ScopedClosureRunner close_runner, re-checks the base::WeakPtr<HidChooser> chooser after the call, and returns early (letting close_runner dismiss the just-created dialog) if the chooser was freed. Only when the chooser survives does it call chooser->SetCloseClosure(close_runner.Release()), so no method is invoked on a freed object.

Key insight
The core mistake was using a base::WeakPtr<HidChooser> across a call (ShowDeviceChooserDialog()) that can re-enter embedder code and free that object, without re-validating the pointer afterward; the fix defers use of the return value into a ScopedClosureRunner and re-checks the weak pointer before dereferencing it.

Attack Path

  1. Set up a guest frame An attacker-controlled page embeds a <controlledframe> or <webview> guest and installs an embedder WebContentsDelegate that reacts to fullscreen-exit notifications.
  2. Trigger the chooser The guest requests a WebHID device, causing ChooseDevice() to call chrome::ShowDeviceChooserDialog(), which invokes ForSecurityDropFullscreen().
  3. Free the chooser mid-call The synchronous ExitFullscreenModeForTab() callback runs embedder script that destroys the guest document, freeing the HidChooser while ShowDeviceChooserDialog() is still on the stack.
  4. Use-after-free write Control returns and the pre-patch code calls chooser->SetCloseClosure(...) on the freed HidChooser, performing a heap use-after-free write.
  5. Exploitation (inferred) Controlling the contents of the reclaimed allocation before the write could turn the UAF into a more powerful primitive, but the patch does not demonstrate any specific sink, offset, or field.

Impact Assessment

An attacker who can host a page embedding a <controlledframe>/<webview> guest and drive it through the WebHID chooser flow while in content fullscreen can free the HidChooser and trigger a heap use-after-free write in the browser process. The precondition is embedder-side control of the fullscreen-exit response so the guest document is destroyed synchronously during ShowDeviceChooserDialog(). The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3. The advisory states only “Use after free in HID” and provides no exploited-in-the-wild or severity rating beyond the bug class.

Changed Functions

FunctionChangeNotes
if
chrome/browser/hid/chrome_hid_delegate.cc
modified
ExitFullscreenInterceptingDelegate
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified
if
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified
ControlledFrameHidChooserTest
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified
InstallIwaAndCreateControlledFrameWithHidPolicy
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified
BindLambdaForTesting
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified
ControlledFramePermissionStatusLeakTest
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified

Files Changed

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

Audit Directions

  • Weak-pointer revalidation across re-entrant calls
    Audit every dereference of a base::WeakPtr (or raw owned pointer) that follows a call which can synchronously re-enter embedder/renderer code, such as fullscreen, permission, or dialog callbacks.
  • Chooser/dialog lifetime
    Review other permission-chooser flows (USB, Serial, Bluetooth) that pass a dialog’s return value straight into a method on a request-scoped object, since ForSecurityDropFullscreen() and similar notifications can destroy that object mid-flow.
  • Guest-frame delegate callbacks
    Examine code reachable from <controlledframe>/<webview> WebContentsDelegate overrides (ExitFullscreenModeForTab() and peers) for synchronous document destruction that outlives assumptions in the caller’s stack frame.
From 78d5d3a255fb511dac5946f624f0a122864370f2 Mon Sep 17 00:00:00 2001
From: Rob Pitkin <[email protected]>
Date: Wed, 16 Sep 2026 10:22:51 -0700
Subject: [PATCH] hid: Guard HidChooser on chooser dialog open

When showing the WebHID device chooser for a <controlledframe> or
<webview>, chrome::ShowDeviceChooserDialog() calls
ForSecurityDropFullscreen(), which notifies the embedder
WebContentsDelegate to exit fullscreen. If the embedder synchronously
destroys the guest document during fullscreen exit, the guest's
HidChooser is freed while ShowDeviceChooserDialog() is still on the
stack.

Directly passing the result to
chooser->SetCloseClosure(ShowDeviceChooserDialog(...)) causes a heap
use-after-free write upon return.

This CL stores the returned close closure in a local variable,
re-validates the base::WeakPtr<HidChooser>, dismisses the newly created
dialog if the chooser was destroyed, and only assigns the close closure
if the chooser survived.

TAG=agy
CONV=93587e9d-6814-4ba9-ab03-9a6f89aa02f2

Bug: 555299641
Change-Id: Ibf2833d8fba4ad3d21c78b2cc98f81b35ee38690
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8338627
Reviewed-by: Matt Reynolds <[email protected]>
Reviewed-by: Alvin Ji <[email protected]>
Commit-Queue: Rob Pitkin <[email protected]>
Reviewed-by: Simon Hangl <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1699014}
---
diff --git a/chrome/browser/hid/chrome_hid_delegate.cc b/chrome/browser/hid/chrome_hid_delegate.cc
index e296d150..67afe05 100644
--- a/chrome/browser/hid/chrome_hid_delegate.cc
+++ b/chrome/browser/hid/chrome_hid_delegate.cc
@@ -8,6 +8,7 @@
 #include <utility>
 
 #include "base/feature_list.h"
+#include "base/functional/callback_helpers.h"
 #include "base/notimplemented.h"
 #include "base/observer_list.h"
 #include "base/scoped_observation.h"
@@ -417,10 +418,14 @@
     return;
   }
 
-  chooser->SetCloseClosure(chrome::ShowDeviceChooserDialog(
+  base::ScopedClosureRunner close_runner(chrome::ShowDeviceChooserDialog(
       render_frame_host,
       std::make_unique<HidChooserController>(
           render_frame_host, std::move(filters), std::move(exclusion_filters),
           std::move(callback))));
+  if (!chooser) {
+    return;
+  }
+  chooser->SetCloseClosure(close_runner.Release());
 }
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS)
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 a34fdd5a..55e110e 100644
--- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
@@ -4,8 +4,12 @@
 
 #include <memory>
 #include <string>
+#include <string_view>
 #include <vector>
 
+#include "base/functional/callback_helpers.h"
+#include "base/memory/raw_ptr.h"
+#include "base/run_loop.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
 #include "base/test/scoped_feature_list.h"
@@ -29,8 +33,10 @@
 #include "components/permissions/mock_chooser_controller_view.h"
 #include "components/prefs/pref_service.h"
 #include "content/public/browser/download_manager.h"
+#include "content/public/browser/hid_chooser.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents.h"
+#include "content/public/browser/web_contents_delegate.h"
 #include "content/public/common/content_client.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
@@ -42,6 +48,9 @@
 #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-forward.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/common/features.h"
+#include "ui/views/test/widget_test.h"
+#include "ui/views/widget/any_widget_observer.h"
+#include "ui/views/widget/widget.h"
 
 using testing::Contains;
 using testing::StartsWith;
@@ -513,6 +522,177 @@
             content::EvalJs(controlled_frame, kTestScript).ExtractString());
 }
 
+namespace {
+
+constexpr std::string_view kPermissionAllowedHost = "permission-allowed.com";
+constexpr std::string_view kChooserBubbleWidgetName =
+    "ChooserBubbleUiViewDelegate";
+
+// Reports content fullscreen so that the chooser dialog's
+// ForSecurityDropFullscreen() call reaches the delegate, and runs
+// `on_exit_fullscreen` when it does.
+class ExitFullscreenInterceptingDelegate : public content::WebContentsDelegate {
+ public:
+  ExitFullscreenInterceptingDelegate(
+      content::WebContentsDelegate* original_delegate,
+      base::OnceClosure on_exit_fullscreen)
+      : original_delegate_(original_delegate),
+        on_exit_fullscreen_(std::move(on_exit_fullscreen)) {}
+
+  void ExitFullscreenModeForTab(content::WebContents* web_contents) override {
+    if (on_exit_fullscreen_) {
+      std::move(on_exit_fullscreen_).Run();
+    }
+    if (original_delegate_) {
+      original_delegate_->ExitFullscreenModeForTab(web_contents);
+    }
+  }
+
+  content::FullscreenState GetFullscreenState(
+      const content::WebContents* web_contents) const override {
+    content::FullscreenState state;
+    state.target_mode = content::FullscreenMode::kContent;
+    return state;
+  }
+
+  content::WebContentsDelegate* original_delegate() const {
+    return original_delegate_;
+  }
+
+ private:
+  raw_ptr<content::WebContentsDelegate> original_delegate_;
+  base::OnceClosure on_exit_fullscreen_;
+};
+
+}  // namespace
+
+class ControlledFrameHidChooserTest
+    : public ControlledFramePermissionRequestTestBase {
+ public:
+  void SetUpOnMainThread() override {
+    ControlledFramePermissionRequestTestBase::SetUpOnMainThread();
+
+    mojo::PendingRemote<device::mojom::HidManager> pending_remote;
+    hid_manager_.Bind(pending_remote.InitWithNewPipeAndPassReceiver());
+    base::RunLoop run_loop;
+    HidChooserContextFactory::GetForProfile(profile())->SetHidManagerForTesting(
+        std::move(pending_remote),
+        base::BindLambdaForTesting(
+            [&run_loop](std::vector<device::mojom::HidDeviceInfoPtr> devices) {
+              run_loop.Quit();
+            }));
+    run_loop.Run();
+
+    hid_manager_.CreateAndAddDevice("physical-device-id", /*vendor_id=*/0x6666,
+                                    /*product_id=*/0x1234, "Test HID Device",
+                                    /*serial_number=*/"",
+                                    device::mojom::HidBusType::kHIDBusTypeUSB);
+  }
+
+ protected:
+  // ChromeWebViewPermissionHelperDelegate::RequestHidPermission() denies the
+  // request outright unless the Controlled Frame embedder's permissions policy
+  // delegates kHid to the guest origin.
+  std::pair<content::RenderFrameHost*, content::RenderFrameHost*>
+  InstallIwaAndCreateControlledFrameWithHidPolicy() {
+    web_app::ManifestBuilder manifest_builder;
+    manifest_builder.AddPermissionsPolicy(
+        network::mojom::PermissionsPolicyFeature::kHid,
+        /*self=*/true,
+        {embedded_https_test_server().GetOrigin(
+            std::string(kPermissionAllowedHost))});
+    return InstallAndOpenIwaThenCreateControlledFrame(
+        /*controlled_frame_host_name=*/kPermissionAllowedHost,
+        /*controlled_frame_src_relative_url=*/"/index.html", manifest_builder);
+  }
+
+ private:
+  base::test::ScopedFeatureList scoped_feature_list_{
+      extensions_features::kEnableWebHidInWebView};
+  device::FakeHidManager hid_manager_;
+};
+
+// Regression test for b/555299641. Showing the chooser dialog drops
+// fullscreen, which gives the embedder a chance to destroy the guest's
+// HidChooser before chrome::ShowDeviceChooserDialog() returns. The orphaned
+// dialog must still be dismissed.
+IN_PROC_BROWSER_TEST_F(ControlledFrameHidChooserTest,
+                       ChooserDialogClosedWhenChooserDestroyedWhileOpening) {
+  auto [app_frame, controlled_frame] =
+      InstallIwaAndCreateControlledFrameWithHidPolicy();
+  ASSERT_TRUE(app_frame);
+  ASSERT_TRUE(controlled_frame);
+
+  SetUpPermissionRequestEventListener(app_frame, "hid",
+                                      /*allow_permission=*/true);
+
+  // The test owns the chooser so that it can be destroyed without tearing down
+  // the guest, isolating the ChromeHidDelegate path under test.
+  std::unique_ptr<content::HidChooser> chooser;
+
+  auto* embedder_contents =
+      content::WebContents::FromRenderFrameHost(app_frame);
+  ASSERT_TRUE(embedder_contents);
+  ExitFullscreenInterceptingDelegate intercepting_delegate(
+      embedder_contents->GetDelegate(),
+      base::BindLambdaForTesting([&]() { chooser.reset(); }));
+  embedder_contents->SetDelegate(&intercepting_delegate);
+  base::ScopedClosureRunner restore_delegate(base::BindOnce(
+      &content::WebContents::SetDelegate, base::Unretained(embedder_contents),
+      intercepting_delegate.original_delegate()));
+
+  bool chooser_dialog_closing = false;
+  views::AnyWidgetObserver widget_observer(views::test::AnyWidgetTestPasskey{});
+  widget_observer.set_closing_callback(
+      base::BindLambdaForTesting([&](views::Widget* widget) {
+        chooser_dialog_closing |= widget->GetName() == kChooserBubbleWidgetName;
+      }));
+  views::NamedWidgetShownWaiter widget_waiter(
+      views::test::AnyWidgetTestPasskey{},
+      std::string(kChooserBubbleWidgetName));
+
+  ChromeHidDelegate hid_delegate;
+  chooser =
+      hid_delegate.RunChooser(controlled_frame, /*filters=*/{},
+                              /*exclusion_filters=*/{}, base::DoNothing());
+  ASSERT_TRUE(chooser);
+
+  widget_waiter.WaitIfNeededAndGet();
+  EXPECT_FALSE(chooser);
+  EXPECT_TRUE(chooser_dialog_closing);
+}
+
+IN_PROC_BROWSER_TEST_F(ControlledFrameHidChooserTest,
+                       ChooserDialogClosedWhenChooserDestroyedAfterOpening) {
+  auto [app_frame, controlled_frame] =
+      InstallIwaAndCreateControlledFrameWithHidPolicy();
+  ASSERT_TRUE(app_frame);
+  ASSERT_TRUE(controlled_frame);
+
+  SetUpPermissionRequestEventListener(app_frame, "hid",
+                                      /*allow_permission=*/true);
+
+  views::NamedWidgetShownWaiter widget_waiter(
+      views::test::AnyWidgetTestPasskey{},
+      std::string(kChooserBubbleWidgetName));
+
+  ChromeHidDelegate hid_delegate;
+  std::unique_ptr<content::HidChooser> chooser =
+      hid_delegate.RunChooser(controlled_frame, /*filters=*/{},
+                              /*exclusion_filters=*/{}, base::DoNothing());
+  ASSERT_TRUE(chooser);
+
+  views::Widget* chooser_widget = widget_waiter.WaitIfNeededAndGet();
+  ASSERT_TRUE(chooser_widget);
+  EXPECT_FALSE(chooser_widget->IsClosed());
+
+  // The close closure was handed to the chooser, so destroying the chooser
+  // dismisses the dialog.
+  views::test::WidgetDestroyedWaiter destroyed_waiter(chooser_widget);
+  chooser.reset();
+  destroyed_waiter.Wait();
+}
+
 class ControlledFramePermissionStatusLeakTest : public ControlledFrameTestBase {
  protected:
   void SetPermission(const GURL& url,
Loading diff…

Regression Test / PoC

shipped with the fix
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 a34fdd5a..55e110e 100644
--- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
@@ -4,8 +4,12 @@
 
 #include <memory>
 #include <string>
+#include <string_view>
 #include <vector>
 
+#include "base/functional/callback_helpers.h"
+#include "base/memory/raw_ptr.h"
+#include "base/run_loop.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
 #include "base/test/scoped_feature_list.h"
@@ -29,8 +33,10 @@
 #include "components/permissions/mock_chooser_controller_view.h"
 #include "components/prefs/pref_service.h"
 #include "content/public/browser/download_manager.h"
+#include "content/public/browser/hid_chooser.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents.h"
+#include "content/public/browser/web_contents_delegate.h"
 #include "content/public/common/content_client.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
@@ -42,6 +48,9 @@
 #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-forward.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/common/features.h"
+#include "ui/views/test/widget_test.h"
+#include "ui/views/widget/any_widget_observer.h"
+#include "ui/views/widget/widget.h"
 
 using testing::Contains;
 using testing::StartsWith;
@@ -513,6 +522,177 @@
             content::EvalJs(controlled_frame, kTestScript).ExtractString());
 }
 
+namespace {
+
+constexpr std::string_view kPermissionAllowedHost = "permission-allowed.com";
+constexpr std::string_view kChooserBubbleWidgetName =
+    "ChooserBubbleUiViewDelegate";
+
+// Reports content fullscreen so that the chooser dialog's
+// ForSecurityDropFullscreen() call reaches the delegate, and runs
+// `on_exit_fullscreen` when it does.
+class ExitFullscreenInterceptingDelegate : public content::WebContentsDelegate {
+ public:
+  ExitFullscreenInterceptingDelegate(
+      content::WebContentsDelegate* original_delegate,
+      base::OnceClosure on_exit_fullscreen)
+      : original_delegate_(original_delegate),
+        on_exit_fullscreen_(std::move(on_exit_fullscreen)) {}
+
+  void ExitFullscreenModeForTab(content::WebContents* web_contents) override {
+    if (on_exit_fullscreen_) {
+      std::move(on_exit_fullscreen_).Run();
+    }
+    if (original_delegate_) {
+      original_delegate_->ExitFullscreenModeForTab(web_contents);
+    }
+  }
+
+  content::FullscreenState GetFullscreenState(
+      const content::WebContents* web_contents) const override {
+    content::FullscreenState state;
+    state.target_mode = content::FullscreenMode::kContent;
+    return state;
+  }
+
+  content::WebContentsDelegate* original_delegate() const {
+    return original_delegate_;
+  }
+
+ private:
+  raw_ptr<content::WebContentsDelegate> original_delegate_;
+  base::OnceClosure on_exit_fullscreen_;
+};
+
+}  // namespace
+
+class ControlledFrameHidChooserTest
+    : public ControlledFramePermissionRequestTestBase {
+ public:
+  void SetUpOnMainThread() override {
+    ControlledFramePermissionRequestTestBase::SetUpOnMainThread();
+
+    mojo::PendingRemote<device::mojom::HidManager> pending_remote;
+    hid_manager_.Bind(pending_remote.InitWithNewPipeAndPassReceiver());
+    base::RunLoop run_loop;
+    HidChooserContextFactory::GetForProfile(profile())->SetHidManagerForTesting(
+        std::move(pending_remote),
+        base::BindLambdaForTesting(
+            [&run_loop](std::vector<device::mojom::HidDeviceInfoPtr> devices) {
+              run_loop.Quit();
+            }));
+    run_loop.Run();
+
+    hid_manager_.CreateAndAddDevice("physical-device-id", /*vendor_id=*/0x6666,
+                                    /*product_id=*/0x1234, "Test HID Device",
+                                    /*serial_number=*/"",
+                                    device::mojom::HidBusType::kHIDBusTypeUSB);
+  }
+
+ protected:
+  // ChromeWebViewPermissionHelperDelegate::RequestHidPermission() denies the
+  // request outright unless the Controlled Frame embedder's permissions policy
+  // delegates kHid to the guest origin.
+  std::pair<content::RenderFrameHost*, content::RenderFrameHost*>
+  InstallIwaAndCreateControlledFrameWithHidPolicy() {
+    web_app::ManifestBuilder manifest_builder;
+    manifest_builder.AddPermissionsPolicy(
+        network::mojom::PermissionsPolicyFeature::kHid,
+        /*self=*/true,
+        {embedded_https_test_server().GetOrigin(
+            std::string(kPermissionAllowedHost))});
+    return InstallAndOpenIwaThenCreateControlledFrame(
+        /*controlled_frame_host_name=*/kPermissionAllowedHost,
+        /*controlled_frame_src_relative_url=*/"/index.html", manifest_builder);
+  }
+
+ private:
+  base::test::ScopedFeatureList scoped_feature_list_{
+      extensions_features::kEnableWebHidInWebView};
+  device::FakeHidManager hid_manager_;
+};
+
+// Regression test for b/555299641. Showing the chooser dialog drops
+// fullscreen, which gives the embedder a chance to destroy the guest's
+// HidChooser before chrome::ShowDeviceChooserDialog() returns. The orphaned
+// dialog must still be dismissed.
+IN_PROC_BROWSER_TEST_F(ControlledFrameHidChooserTest,
+                       ChooserDialogClosedWhenChooserDestroyedWhileOpening) {
+  auto [app_frame, controlled_frame] =
+      InstallIwaAndCreateControlledFrameWithHidPolicy();
+  ASSERT_TRUE(app_frame);
+  ASSERT_TRUE(controlled_frame);
+
+  SetUpPermissionRequestEventListener(app_frame, "hid",
+                                      /*allow_permission=*/true);
+
+  // The test owns the chooser so that it can be destroyed without tearing down
+  // the guest, isolating the ChromeHidDelegate path under test.
+  std::unique_ptr<content::HidChooser> chooser;
+
+  auto* embedder_contents =
+      content::WebContents::FromRenderFrameHost(app_frame);
+  ASSERT_TRUE(embedder_contents);
+  ExitFullscreenInterceptingDelegate intercepting_delegate(
+      embedder_contents->GetDelegate(),
+      base::BindLambdaForTesting([&]() { chooser.reset(); }));
+  embedder_contents->SetDelegate(&intercepting_delegate);
+  base::ScopedClosureRunner restore_delegate(base::BindOnce(
+      &content::WebContents::SetDelegate, base::Unretained(embedder_contents),
+      intercepting_delegate.original_delegate()));
+
+  bool chooser_dialog_closing = false;
+  views::AnyWidgetObserver widget_observer(views::test::AnyWidgetTestPasskey{});
+  widget_observer.set_closing_callback(
+      base::BindLambdaForTesting([&](views::Widget* widget) {
+        chooser_dialog_closing |= widget->GetName() == kChooserBubbleWidgetName;
+      }));
+  views::NamedWidgetShownWaiter widget_waiter(
+      views::test::AnyWidgetTestPasskey{},
+      std::string(kChooserBubbleWidgetName));
+
+  ChromeHidDelegate hid_delegate;
+  chooser =
+      hid_delegate.RunChooser(controlled_frame, /*filters=*/{},
+                              /*exclusion_filters=*/{}, base::DoNothing());
+  ASSERT_TRUE(chooser);
+
+  widget_waiter.WaitIfNeededAndGet();
+  EXPECT_FALSE(chooser);
+  EXPECT_TRUE(chooser_dialog_closing);
+}
+
+IN_PROC_BROWSER_TEST_F(ControlledFrameHidChooserTest,
+                       ChooserDialogClosedWhenChooserDestroyedAfterOpening) {
+  auto [app_frame, controlled_frame] =
+      InstallIwaAndCreateControlledFrameWithHidPolicy();
+  ASSERT_TRUE(app_frame);
+  ASSERT_TRUE(controlled_frame);
+
+  SetUpPermissionRequestEventListener(app_frame, "hid",
+                                      /*allow_permission=*/true);
+
+  views::NamedWidgetShownWaiter widget_waiter(
+      views::test::AnyWidgetTestPasskey{},
+      std::string(kChooserBubbleWidgetName));
+
+  ChromeHidDelegate hid_delegate;
+  std::unique_ptr<content::HidChooser> chooser =
+      hid_delegate.RunChooser(controlled_frame, /*filters=*/{},
+                              /*exclusion_filters=*/{}, base::DoNothing());
+  ASSERT_TRUE(chooser);
+
+  views::Widget* chooser_widget = widget_waiter.WaitIfNeededAndGet();
+  ASSERT_TRUE(chooser_widget);
+  EXPECT_FALSE(chooser_widget->IsClosed());
+
+  // The close closure was handed to the chooser, so destroying the chooser
+  // dismisses the dialog.
+  views::test::WidgetDestroyedWaiter destroyed_waiter(chooser_widget);
+  chooser.reset();
+  destroyed_waiter.Wait();
+}
+
 class ControlledFramePermissionStatusLeakTest : public ControlledFrameTestBase {
  protected:
   void SetPermission(const GURL& url,
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.