Medium watch CVSS 8.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FileSystem
DescriptionMissing authorization in FileSystem
ComponentFileSystem
Sub-componentDevice choosers
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.
Tracker495429423
Fix commit33ba921e9fc1 (chromium/src) +152/-1
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-23 (per vendor advisory)
Fix landed2026-08-06 (public git)
Disclosed2026-09-08
Patch gap33 days — fix public in source before the advisory

Background

Transient user activation
a short-lived, gesture-derived state on a frame that permits privileged, user-visible actions (like opening a device chooser) and is consumed on use, so a page cannot act on its own.
Device chooser
the browser-drawn dialog shown by WebUSB, WebHID, and Web Serial (RunChooser / RunChooserInternal) that lets the user pick a physical device to grant a site.
`FrameTreeNode::UpdateUserActivationState`
the browser-process API that updates a frame’s activation; called with kConsumeTransientActivation it returns false (and consumes nothing) when there is no transient activation to consume.
Renderer/browser trust boundary
the renderer is untrusted and can send arbitrary IPCs, so any security precondition checked only in the renderer must be re-checked in the browser process.

Root Cause Analysis

The requirement that WebUSB GetPermission, WebHID RequestDevice, and Web Serial RequestPort only run when the calling frame has transient user activation was enforced only in the renderer before the IPC was sent, per the commit message; the browser-side handlers in hid_service.cc, serial_service.cc, and web_usb_service_impl.cc never re-verified it. Because the renderer is untrusted, a renderer that skips or forges the renderer-side check can invoke these Mojo methods with no genuine user gesture, and the browser would proceed to show the device chooser.

The fix adds FrameTreeNode::From(render_frame_host_)->UpdateUserActivationState(kConsumeTransientActivation, kNone) to each handler’s early-return guard, so a frame with no transient activation causes the call to return the empty/null result before reaching RunChooser. This restores the browser-process authorization invariant and, per the commit message, matches what FileSystemAccessManagerImpl::ChooseEntries already enforced — the added FileSystemAccessManagerImplTest.ChooseEntries_WithoutUserActivation test confirms the FileSystem path was already correct and returns kPermissionDenied with "User activation required.". This patch only adds a gating check; it does not change any device-access or memory-handling logic.

Key insight
The single mistake was trusting a renderer-side user-activation check as authoritative and omitting the mirror check in the browser process; the fix closes the gap by consuming/validating transient activation inside each browser-side chooser handler before the chooser is shown.

Attack Path

  1. Reach the browser handler without a gesture A malicious or compromised renderer sends the RequestDevice / RequestPort / GetPermission Mojo IPC directly, bypassing the renderer-only activation check that previously gated these calls.
  2. Browser omits the activation check Pre-patch, the handlers checked only render_frame_host_ and the delegate’s CanRequestDevicePermission / delegate presence, never the frame’s transient activation, so control flow proceeded to delegate->RunChooser(...).
  3. Chooser shown unsolicited The device chooser dialog is displayed with no user activation, letting a page pop the USB/HID/Serial permission prompt at a time it chooses. (inferred)
  4. Trick the user into granting a device By timing or overlaying the unsolicited chooser (e.g. UI redressing), an attacker could try to induce the user to grant access to a physical device, which yields powerful device I/O; the patch does not demonstrate this and it is beyond what the diff shows. (inferred)

Impact Assessment

An attacker controlling page content or a compromised renderer could cause the WebUSB/WebHID/Web Serial device chooser to be displayed in the browser UI without any transient user activation, defeating the gesture requirement that guards these permission prompts. The concrete escalation — deceiving a user into granting device access, and any consequence of that access — is not demonstrated by the diff and is (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3. Preconditions: the target frame has no transient user activation, and the delegate would otherwise permit the request.

Changed Functions

FunctionChangeNotes
if
content/browser/serial/serial_service.cc
modified
TEST_F
content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
modified
if
content/browser/hid/hid_service_unittest.cc
modified
TEST_P
content/browser/hid/hid_service_unittest.cc
modified
TEST_F
content/browser/serial/serial_unittest.cc
modified
TEST_F
content/browser/usb/web_usb_service_impl_unittest.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
  • content/browser/hid/hid_service.cc
  • content/browser/serial/serial_service.cc
  • content/browser/usb/web_usb_service_impl.cc
  • content/browser/usb/web_usb_service_impl_unittest.cc
  • content/browser/hid/hid_service_unittest.cc
  • content/browser/serial/serial_unittest.cc

Audit Directions

  • Renderer-only precondition checks
    Search other Mojo service handlers (WebBluetooth, Web Share, Contacts, Presentation — named in the commit as follow-up CLs) for security checks (user activation, permissions policy, focus) enforced only in Blink before the IPC and never re-verified in the browser process.
  • Chooser/prompt entry points
    Audit every browser-side call that opens a user-facing chooser or permission dialog (RunChooser, RunChooserInternal) to confirm it consumes or validates transient activation via FrameTreeNode::UpdateUserActivationState before display.
  • Service-worker vs frame bindings
    Review handlers that use render_frame_host_ where a service-worker binding leaves it null, checking for missing null guards and for activation checks that assume a frame context always exists.
From 33ba921e9fc12716eb567f66b849f392ba8b7464 Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <[email protected]>
Date: Thu, 06 Aug 2026 00:55:43 -0700
Subject: [PATCH] [device choosers] Check user activation in the browser process

WebUSB GetPermission, WebHID RequestDevice and Web Serial RequestPort
require transient user activation, but the check was only performed in
the renderer before sending the IPC. Mirror the same check in the
browser-side handlers so the chooser is not shown when the calling frame
has no transient user activation, matching what
FileSystemAccessManagerImpl::ChooseEntries already does.

For WebUSB this also adds the missing null check on `render_frame_host_`
for the service-worker binding.

WebBluetooth, Web Share, Contacts and Presentation will be handled in
follow-up CLs.

TAG=agy
CONV=0394b832-89c4-4d8b-b5f1-739e90b8d937

Bug: 495429423
Change-Id: Ia8e42a4f44860f860132dde1d28a62b1f78b3892
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8210800
Reviewed-by: Rakina Zata Amni <[email protected]>
Commit-Queue: Eriko Kurimoto <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1674775}
---
diff --git a/content/browser/hid/hid_service.cc b/content/browser/hid/hid_service.cc
index fe153f8..05ddfb8 100644
--- a/content/browser/hid/hid_service.cc
+++ b/content/browser/hid/hid_service.cc
@@ -13,6 +13,7 @@
 #include "base/functional/callback.h"
 #include "base/functional/callback_helpers.h"
 #include "build/build_config.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/service_worker/service_worker_context_core.h"
 #include "content/browser/service_worker/service_worker_hid_delegate_observer.h"
 #include "content/browser/web_contents/web_contents_impl.h"
@@ -27,6 +28,8 @@
 #include "services/device/public/cpp/device_features.h"
 #include "services/device/public/cpp/hid/hid_report_utils.h"
 #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom.h"
+#include "third_party/blink/public/mojom/frame/user_activation_notification_type.mojom.h"
+#include "third_party/blink/public/mojom/frame/user_activation_update_types.mojom.h"
 
 namespace content {
 
@@ -419,6 +422,11 @@
     RequestDeviceCallback callback) {
   HidDelegate* delegate = GetContentClient()->browser()->GetHidDelegate();
   if (!render_frame_host_ ||
+      !FrameTreeNode::From(render_frame_host_)
+           ->UpdateUserActivationState(
+               blink::mojom::UserActivationUpdateType::
+                   kConsumeTransientActivation,
+               blink::mojom::UserActivationNotificationType::kNone) ||
       !delegate->CanRequestDevicePermission(GetBrowserContext(), origin_)) {
     std::move(callback).Run(std::vector<device::mojom::HidDeviceInfoPtr>());
     return;
diff --git a/content/browser/serial/serial_service.cc b/content/browser/serial/serial_service.cc
index 7ca0f1d..929a7db 100644
--- a/content/browser/serial/serial_service.cc
+++ b/content/browser/serial/serial_service.cc
@@ -12,6 +12,7 @@
 #include "base/strings/strcat.h"
 #include "base/strings/utf_string_conversions.h"
 #include "content/browser/back_forward_cache/back_forward_cache_disable.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/public/browser/back_forward_cache.h"
 #include "content/public/browser/content_browser_client.h"
@@ -23,6 +24,8 @@
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "services/device/public/mojom/serial.mojom.h"
 #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom.h"
+#include "third_party/blink/public/mojom/frame/user_activation_notification_type.mojom.h"
+#include "third_party/blink/public/mojom/frame/user_activation_update_types.mojom.h"
 
 namespace content {
 
@@ -87,7 +90,12 @@
         allowed_bluetooth_service_class_ids,
     RequestPortCallback callback) {
   SerialDelegate* delegate = GetContentClient()->browser()->GetSerialDelegate();
-  if (!delegate) {
+  if (!delegate ||
+      !FrameTreeNode::From(&render_frame_host())
+           ->UpdateUserActivationState(
+               blink::mojom::UserActivationUpdateType::
+                   kConsumeTransientActivation,
+               blink::mojom::UserActivationNotificationType::kNone)) {
     std::move(callback).Run(nullptr);
     return;
   }
diff --git a/content/browser/usb/web_usb_service_impl.cc b/content/browser/usb/web_usb_service_impl.cc
index 389c9cf..d91b071 100644
--- a/content/browser/usb/web_usb_service_impl.cc
+++ b/content/browser/usb/web_usb_service_impl.cc
@@ -11,6 +11,7 @@
 #include "base/functional/bind.h"
 #include "base/memory/raw_ptr.h"
 #include "build/android_buildflags.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/service_worker/service_worker_context_core.h"
 #include "content/browser/service_worker/service_worker_usb_delegate_observer.h"
@@ -26,6 +27,8 @@
 #include "services/device/public/mojom/usb_enumeration_options.mojom.h"
 #include "services/device/public/mojom/usb_manager_client.mojom.h"
 #include "third_party/blink/public/common/features_generated.h"
+#include "third_party/blink/public/mojom/frame/user_activation_notification_type.mojom.h"
+#include "third_party/blink/public/mojom/frame/user_activation_update_types.mojom.h"
 
 namespace content {
 
@@ -346,6 +349,15 @@
     return;
   }
 
+  if (!FrameTreeNode::From(render_frame_host_)
+           ->UpdateUserActivationState(
+               blink::mojom::UserActivationUpdateType::
+                   kConsumeTransientActivation,
+               blink::mojom::UserActivationNotificationType::kNone)) {
+    std::move(callback).Run(nullptr);
+    return;
+  }
+
   usb_chooser_ = delegate->RunChooser(*render_frame_host_, std::move(options),
                                       std::move(callback));
 }
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index 77258dc1..00e479c 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -1638,6 +1638,41 @@
   ASSERT_TRUE(future.Wait());
 }
 
+TEST_F(FileSystemAccessManagerImplTest, ChooseEntries_WithoutUserActivation) {
+  mojo::Remote<blink::mojom::FileSystemAccessManager> manager_remote;
+  FileSystemAccessManagerImpl::BindingContext binding_context = {
+      kTestStorageKey, kTestURL,
+      web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+  manager_->BindReceiver(binding_context,
+                         manager_remote.BindNewPipeAndPassReceiver());
+
+  EXPECT_CALL(permission_context_,
+              CanObtainReadPermission(kTestStorageKey.origin()))
+      .WillOnce(testing::Return(true));
+
+  auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+      blink::mojom::AcceptsTypesInfo::New(
+          std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+          /*include_accepts_all=*/true),
+      /*can_select_multiple_files=*/false);
+  auto picker_options = blink::mojom::FilePickerOptions::New(
+      blink::mojom::TypeSpecificFilePickerOptionsUnion::
+          NewOpenFilePickerOptions(std::move(open_file_picker_options)),
+      /*starting_directory_id=*/std::string(),
+      blink::mojom::FilePickerStartInOptionsUnionPtr());
+
+  base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr,
+                         std::vector<blink::mojom::FileSystemAccessEntryPtr>>
+      future;
+  manager_remote->ChooseEntries(std::move(picker_options),
+                                future.GetCallback());
+  EXPECT_TRUE(future.Wait());
+  EXPECT_EQ(future.Get<0>()->status,
+            blink::mojom::FileSystemAccessStatus::kPermissionDenied);
+  EXPECT_EQ(future.Get<0>()->message, "User activation required.");
+  EXPECT_TRUE(future.Get<1>().empty());
+}
+
 // Test opening multiple files where all selected files are safe (i.e., they
 // pass the sensitive entry check with `kAllowed`). Verifies that the recursive
 // check successfully iterates through all entries and returns handles for all
diff --git a/content/browser/hid/hid_service_unittest.cc b/content/browser/hid/hid_service_unittest.cc
index 8c2ba32..6e61556b 100644
--- a/content/browser/hid/hid_service_unittest.cc
+++ b/content/browser/hid/hid_service_unittest.cc
@@ -443,6 +443,9 @@
   ConnectDevice(*device_info);
 
   if (service_creation_type == kCreateUsingRenderFrameHost) {
+    static_cast<TestWebContents*>(web_contents_)
+        ->GetPrimaryMainFrame()
+        ->SimulateUserActivation();
     EXPECT_CALL(hid_delegate(), CanRequestDevicePermission)
         .WillOnce(Return(true));
     EXPECT_CALL(hid_delegate(), RunChooserInternal)
@@ -468,6 +471,28 @@
   }
 }
 
+TEST_P(HidServiceTest, RequestDeviceWithoutUserActivation) {
+  const auto& service = GetService(GetParam());
+
+  ON_CALL(hid_delegate(), CanRequestDevicePermission)
+      .WillByDefault(Return(true));
+  EXPECT_CALL(hid_delegate(), RunChooserInternal).Times(0);
+
+  base::RunLoop run_loop;
+  std::vector<device::mojom::HidDeviceInfoPtr> chosen_devices;
+  service->RequestDevice(
+      std::vector<blink::mojom::HidDeviceFilterPtr>(),
+      std::vector<blink::mojom::HidDeviceFilterPtr>(),
+      base::BindLambdaForTesting(
+          [&run_loop,
+           &chosen_devices](std::vector<device::mojom::HidDeviceInfoPtr> d) {
+            chosen_devices = std::move(d);
+            run_loop.Quit();
+          }));
+  run_loop.Run();
+  EXPECT_EQ(0u, chosen_devices.size());
+}
+
 TEST_P(HidServiceTest, OpenAndCloseHidConnection) {
   auto service_creation_type = GetParam();
   const auto& service = GetService(service_creation_type);
diff --git a/content/browser/serial/serial_unittest.cc b/content/browser/serial/serial_unittest.cc
index dc1e6c1..a14f01d5 100644
--- a/content/browser/serial/serial_unittest.cc
+++ b/content/browser/serial/serial_unittest.cc
@@ -182,6 +182,41 @@
   EXPECT_TRUE(has_bluetooth);
 }
 
+TEST_F(SerialTest, RequestPort) {
+  NavigateAndCommit(GURL(kTestUrl));
+
+  contents()->GetPrimaryMainFrame()->SimulateUserActivation();
+
+  mojo::Remote<blink::mojom::SerialService> service;
+  contents()->GetPrimaryMainFrame()->BindSerialService(
+      service.BindNewPipeAndPassReceiver());
+
+  EXPECT_CALL(delegate(), CanRequestPortPermission).WillOnce(Return(true));
+  auto port = device::mojom::SerialPortInfo::New();
+  port->token = base::UnguessableToken::Create();
+  EXPECT_CALL(delegate(), RunChooserInternal)
+      .WillOnce(Return(testing::ByMove(std::move(port))));
+
+  TestFuture<blink::mojom::SerialPortInfoPtr> future;
+  service->RequestPort({}, {}, future.GetCallback());
+  EXPECT_FALSE(future.Get().is_null());
+}
+
+TEST_F(SerialTest, RequestPortWithoutUserActivation) {
+  NavigateAndCommit(GURL(kTestUrl));
+
+  mojo::Remote<blink::mojom::SerialService> service;
+  contents()->GetPrimaryMainFrame()->BindSerialService(
+      service.BindNewPipeAndPassReceiver());
+
+  ON_CALL(delegate(), CanRequestPortPermission).WillByDefault(Return(true));
+  EXPECT_CALL(delegate(), RunChooserInternal).Times(0);
+
+  TestFuture<blink::mojom::SerialPortInfoPtr> future;
+  service->RequestPort({}, {}, future.GetCallback());
+  EXPECT_TRUE(future.Get().is_null());
+}
+
 TEST_F(SerialTest, OpenAndClosePort) {
   NavigateAndCommit(GURL(kTestUrl));
 
diff --git a/content/browser/usb/web_usb_service_impl_unittest.cc b/content/browser/usb/web_usb_service_impl_unittest.cc
index cb4e8144..64bfe83a 100644
--- a/content/browser/usb/web_usb_service_impl_unittest.cc
+++ b/content/browser/usb/web_usb_service_impl_unittest.cc
@@ -406,6 +406,34 @@
 
 using WebUsbServiceImplFrameTest = WebUsbServiceImplBaseTest;
 
+TEST_F(WebUsbServiceImplFrameTest, GetPermission) {
+  const auto& service = GetService(kCreateForFrame);
+
+  contents()->GetPrimaryMainFrame()->SimulateUserActivation();
+
+  auto device_info = ConnectDevice(CreateFakeDevice(), nullptr);
+  EXPECT_CALL(delegate(), CanRequestDevicePermission).WillOnce(Return(true));
+  EXPECT_CALL(delegate(), RunChooserInternal)
+      .WillOnce(Return(testing::ByMove(device_info->Clone())));
+
+  TestFuture<device::mojom::UsbDeviceInfoPtr> future;
+  service->GetPermission(blink::mojom::WebUsbRequestDeviceOptions::New(),
+                         future.GetCallback());
+  EXPECT_FALSE(future.Get().is_null());
+}
+
+TEST_F(WebUsbServiceImplFrameTest, GetPermissionWithoutUserActivation) {
+  const auto& service = GetService(kCreateForFrame);
+
+  ON_CALL(delegate(), CanRequestDevicePermission).WillByDefault(Return(true));
+  EXPECT_CALL(delegate(), RunChooserInternal).Times(0);
+
+  TestFuture<device::mojom::UsbDeviceInfoPtr> future;
+  service->GetPermission(blink::mojom::WebUsbRequestDeviceOptions::New(),
+                         future.GetCallback());
+  EXPECT_TRUE(future.Get().is_null());
+}
+
 TEST_F(WebUsbServiceImplFrameTest, OpenAndNavigateCrossOrigin) {
   const auto origin = url::Origin::Create(GURL(kDefaultTestUrl));
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
index 77258dc1..00e479c 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl_unittest.cc
@@ -1638,6 +1638,41 @@
   ASSERT_TRUE(future.Wait());
 }
 
+TEST_F(FileSystemAccessManagerImplTest, ChooseEntries_WithoutUserActivation) {
+  mojo::Remote<blink::mojom::FileSystemAccessManager> manager_remote;
+  FileSystemAccessManagerImpl::BindingContext binding_context = {
+      kTestStorageKey, kTestURL,
+      web_contents_->GetPrimaryMainFrame()->GetGlobalId()};
+  manager_->BindReceiver(binding_context,
+                         manager_remote.BindNewPipeAndPassReceiver());
+
+  EXPECT_CALL(permission_context_,
+              CanObtainReadPermission(kTestStorageKey.origin()))
+      .WillOnce(testing::Return(true));
+
+  auto open_file_picker_options = blink::mojom::OpenFilePickerOptions::New(
+      blink::mojom::AcceptsTypesInfo::New(
+          std::vector<blink::mojom::ChooseFileSystemEntryAcceptsOptionPtr>(),
+          /*include_accepts_all=*/true),
+      /*can_select_multiple_files=*/false);
+  auto picker_options = blink::mojom::FilePickerOptions::New(
+      blink::mojom::TypeSpecificFilePickerOptionsUnion::
+          NewOpenFilePickerOptions(std::move(open_file_picker_options)),
+      /*starting_directory_id=*/std::string(),
+      blink::mojom::FilePickerStartInOptionsUnionPtr());
+
+  base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr,
+                         std::vector<blink::mojom::FileSystemAccessEntryPtr>>
+      future;
+  manager_remote->ChooseEntries(std::move(picker_options),
+                                future.GetCallback());
+  EXPECT_TRUE(future.Wait());
+  EXPECT_EQ(future.Get<0>()->status,
+            blink::mojom::FileSystemAccessStatus::kPermissionDenied);
+  EXPECT_EQ(future.Get<0>()->message, "User activation required.");
+  EXPECT_TRUE(future.Get<1>().empty());
+}
+
 // Test opening multiple files where all selected files are safe (i.e., they
 // pass the sensitive entry check with `kAllowed`). Verifies that the recursive
 // check successfully iterates through all entries and returns handles for all
diff --git a/content/browser/hid/hid_service_unittest.cc b/content/browser/hid/hid_service_unittest.cc
index 8c2ba32..6e61556b 100644
--- a/content/browser/hid/hid_service_unittest.cc
+++ b/content/browser/hid/hid_service_unittest.cc
@@ -443,6 +443,9 @@
   ConnectDevice(*device_info);
 
   if (service_creation_type == kCreateUsingRenderFrameHost) {
+    static_cast<TestWebContents*>(web_contents_)
+        ->GetPrimaryMainFrame()
+        ->SimulateUserActivation();
     EXPECT_CALL(hid_delegate(), CanRequestDevicePermission)
         .WillOnce(Return(true));
     EXPECT_CALL(hid_delegate(), RunChooserInternal)
@@ -468,6 +471,28 @@
   }
 }
 
+TEST_P(HidServiceTest, RequestDeviceWithoutUserActivation) {
+  const auto& service = GetService(GetParam());
+
+  ON_CALL(hid_delegate(), CanRequestDevicePermission)
+      .WillByDefault(Return(true));
+  EXPECT_CALL(hid_delegate(), RunChooserInternal).Times(0);
+
+  base::RunLoop run_loop;
+  std::vector<device::mojom::HidDeviceInfoPtr> chosen_devices;
+  service->RequestDevice(
+      std::vector<blink::mojom::HidDeviceFilterPtr>(),
+      std::vector<blink::mojom::HidDeviceFilterPtr>(),
+      base::BindLambdaForTesting(
+          [&run_loop,
+           &chosen_devices](std::vector<device::mojom::HidDeviceInfoPtr> d) {
+            chosen_devices = std::move(d);
+            run_loop.Quit();
+          }));
+  run_loop.Run();
+  EXPECT_EQ(0u, chosen_devices.size());
+}
+
 TEST_P(HidServiceTest, OpenAndCloseHidConnection) {
   auto service_creation_type = GetParam();
   const auto& service = GetService(service_creation_type);
diff --git a/content/browser/serial/serial_unittest.cc b/content/browser/serial/serial_unittest.cc
index dc1e6c1..a14f01d5 100644
--- a/content/browser/serial/serial_unittest.cc
+++ b/content/browser/serial/serial_unittest.cc
@@ -182,6 +182,41 @@
   EXPECT_TRUE(has_bluetooth);
 }
 
+TEST_F(SerialTest, RequestPort) {
+  NavigateAndCommit(GURL(kTestUrl));
+
+  contents()->GetPrimaryMainFrame()->SimulateUserActivation();
+
+  mojo::Remote<blink::mojom::SerialService> service;
+  contents()->GetPrimaryMainFrame()->BindSerialService(
+      service.BindNewPipeAndPassReceiver());
+
+  EXPECT_CALL(delegate(), CanRequestPortPermission).WillOnce(Return(true));
+  auto port = device::mojom::SerialPortInfo::New();
+  port->token = base::UnguessableToken::Create();
+  EXPECT_CALL(delegate(), RunChooserInternal)
+      .WillOnce(Return(testing::ByMove(std::move(port))));
+
+  TestFuture<blink::mojom::SerialPortInfoPtr> future;
+  service->RequestPort({}, {}, future.GetCallback());
+  EXPECT_FALSE(future.Get().is_null());
+}
+
+TEST_F(SerialTest, RequestPortWithoutUserActivation) {
+  NavigateAndCommit(GURL(kTestUrl));
+
+  mojo::Remote<blink::mojom::SerialService> service;
+  contents()->GetPrimaryMainFrame()->BindSerialService(
+      service.BindNewPipeAndPassReceiver());
+
+  ON_CALL(delegate(), CanRequestPortPermission).WillByDefault(Return(true));
+  EXPECT_CALL(delegate(), RunChooserInternal).Times(0);
+
+  TestFuture<blink::mojom::SerialPortInfoPtr> future;
+  service->RequestPort({}, {}, future.GetCallback());
+  EXPECT_TRUE(future.Get().is_null());
+}
+
 TEST_F(SerialTest, OpenAndClosePort) {
   NavigateAndCommit(GURL(kTestUrl));
diff --git a/content/browser/usb/web_usb_service_impl_unittest.cc b/content/browser/usb/web_usb_service_impl_unittest.cc
index cb4e8144..64bfe83a 100644
--- a/content/browser/usb/web_usb_service_impl_unittest.cc
+++ b/content/browser/usb/web_usb_service_impl_unittest.cc
@@ -406,6 +406,34 @@
 
 using WebUsbServiceImplFrameTest = WebUsbServiceImplBaseTest;
 
+TEST_F(WebUsbServiceImplFrameTest, GetPermission) {
+  const auto& service = GetService(kCreateForFrame);
+
+  contents()->GetPrimaryMainFrame()->SimulateUserActivation();
+
+  auto device_info = ConnectDevice(CreateFakeDevice(), nullptr);
+  EXPECT_CALL(delegate(), CanRequestDevicePermission).WillOnce(Return(true));
+  EXPECT_CALL(delegate(), RunChooserInternal)
+      .WillOnce(Return(testing::ByMove(device_info->Clone())));
+
+  TestFuture<device::mojom::UsbDeviceInfoPtr> future;
+  service->GetPermission(blink::mojom::WebUsbRequestDeviceOptions::New(),
+                         future.GetCallback());
+  EXPECT_FALSE(future.Get().is_null());
+}
+
+TEST_F(WebUsbServiceImplFrameTest, GetPermissionWithoutUserActivation) {
+  const auto& service = GetService(kCreateForFrame);
+
+  ON_CALL(delegate(), CanRequestDevicePermission).WillByDefault(Return(true));
+  EXPECT_CALL(delegate(), RunChooserInternal).Times(0);
+
+  TestFuture<device::mojom::UsbDeviceInfoPtr> future;
+  service->GetPermission(blink::mojom::WebUsbRequestDeviceOptions::New(),
+                         future.GetCallback());
+  EXPECT_TRUE(future.Get().is_null());
+}
+
 TEST_F(WebUsbServiceImplFrameTest, OpenAndNavigateCrossOrigin) {
   const auto origin = url::Origin::Create(GURL(kDefaultTestUrl));
Loading diff…

Original Bug Report

reported by [email protected]

User Activation bypass in FileSystemAccessManagerImpl

Flapjack (go/flapjack), an LLM-powered static analysis tool, has identified the following potential security issue.

Overview: Several sensitive Mojo interfaces in the browser process fail to properly verify transient user activation before displaying UI choosers or pickers. A compromised renderer can bypass Blink’s security checks and directly invoke these interfaces to trigger dialogs without genuine user intent. When combined with an ‘Enter key holding’ social engineering attack, this can lead to unauthorized file system write access or hardware device access.

Affected files:

  • content/browser/file_system_access/file_system_access_manager_impl.cc
  • content/browser/usb/web_usb_service_impl.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.cc
  • content/browser/serial/serial_service.cc
  • content/browser/hid/hid_service.cc
  • chrome/browser/webshare/share_service_impl.cc
  • content/browser/contacts/contacts_manager_impl.cc
  • content/browser/presentation/presentation_service_impl.cc

Estimated timestamp from git blame: 2025-12-23

Summary

There is a potential systemic vulnerability across multiple browser-side Mojo interface implementations where transient user activation is either improperly verified or entirely unchecked before displaying sensitive UI elements (like file pickers and device choosers).

Chrome’s security architecture (User Activation v2) dictates that the browser process must be the authoritative source of truth for user activation. While Blink (the renderer) performs checks before sending these Mojo messages, a compromised renderer can bypass the Blink bindings and call the Mojo methods directly. Without robust browser-side enforcement, an attacker can forcefully display these dialogs and potentially exploit them via social engineering.

Technical Details

  1. File System Access API (Flawed Check): In content/browser/file_system_access/file_system_access_manager_impl.cc (FileSystemAccessManagerImpl::ChooseEntries), the code attempts to consume user activation:

    if (GetContentClient()->browser()->IsTransientActivationRequiredForShowFileOrDirectoryPicker(WebContents::FromRenderFrameHost(rfh))) {
      FrameTreeNode::From(rfh)->UpdateUserActivationState(
          blink::mojom::UserActivationUpdateType::kConsumeTransientActivation,
          blink::mojom::UserActivationNotificationType::kNone);
    }
    // Execution continues regardless of the return value...
    

    The method UpdateUserActivationState returns a bool indicating whether transient activation was actually present and successfully consumed. However, ChooseEntries completely ignores this return value and proceeds to show the file picker even if it evaluates to false.

  2. Hardware APIs & Other Pickers (Missing Checks): The browser-side implementations for the following APIs do not perform any transient user activation checks before initiating their respective device choosers, relying entirely on renderer-side enforcement:

    • WebUSB: WebUsbServiceImpl::GetPermission (content/browser/usb/web_usb_service_impl.cc)
    • WebBluetooth: WebBluetoothServiceImpl::RequestDeviceImpl (content/browser/bluetooth/web_bluetooth_service_impl.cc)
    • WebSerial: SerialService::RequestPort (content/browser/serial/serial_service.cc)
    • WebHID: HidService::RequestDevice (content/browser/hid/hid_service.cc)
    • Web Share: ShareServiceImpl::Share (chrome/browser/webshare/share_service_impl.cc)
    • Contacts: ContactsManagerImpl::Select (content/browser/contacts/contacts_manager_impl.cc)

Potential Attack Scenario

Note: The following steps are theoretical as the Flapjack LLM agent does not execute code, but they trace the logical flow of the vulnerability based on the codebase.

  1. Renderer Compromise & Social Engineering: An attacker compromises the sandboxed renderer process (e.g., via a V8 bug). The attacker’s webpage displays a lure (like a game) tricking the user into holding down the “Enter” key.
  2. Mojo Invocation: Bypassing Blink’s security checks, the compromised renderer directly sends a ChooseEntries Mojo message to the browser process. It specifies a SaveFilePickerOptions targeting the user’s Downloads folder with an attacker-controlled suggested_name (e.g., malware.bat).
  3. Browser-Side Bypass: The browser process receives the message. It calls UpdateUserActivationState(kConsumeTransientActivation). Since holding the Enter key does not constitute a valid, recent transient activation in the browser’s state, this returns false. However, because the code ignores the return value, the browser proceeds.
  4. Dialog Steals Focus: The native OS Save File Picker dialog appears and instantly intercepts window focus.
  5. Automatic Confirmation: Because the user is already holding the “Enter” key, the OS immediately registers an “Enter” keystroke on the newly focused dialog. This triggers the default “Save” action.
  6. Sandbox Escape / Unauthorized Access: The dialog closes, and the browser process grants the compromised renderer a valid FileSystemAccessFileHandle for malware.bat, allowing the attacker to silently write arbitrary payloads to the user’s disk. This identical pattern can be used to silently grant access to connected USB/Bluetooth/Serial hardware.

Recommendation

All sensitive Mojo methods that trigger browser-side choosers or pickers must strictly verify that transient user activation is present in the calling frame.

  • For ChooseEntries, the return value of UpdateUserActivationState(kConsumeTransientActivation) must be checked, and the request should be rejected (e.g., returning kPermissionDenied) if it is false.
  • For the hardware and sharing APIs, a similar browser-side check to consume transient user activation must be implemented before calling RunChooser or equivalent.

Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f


Results from Flapjack so far have been promising, but it can be wrong in its deductions. At this time, it does not produce proof of concepts or fuzzer tests. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve Flapjack’s 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