Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Bluetooth
DescriptionInsufficient policy enforcement in Bluetooth
ComponentBluetooth
Bug ClassLogic Error
Tracker496371586
Fix commite6b4397484c4 (chromium/src) +33/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
for
content/browser/bluetooth/web_bluetooth_service_impl.cc
modified

Files Changed

  • content/browser/bluetooth/web_bluetooth_service_impl.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.h
  • content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
From e6b4397484c484f790d1ba2c75fd1c0b59ab6182 Mon Sep 17 00:00:00 2001
From: Rob Pitkin <robpitkin@google.com>
Date: Tue, 05 May 2026 16:12:32 -0700
Subject: [PATCH] Web Bluetooth: Abort pending watchAdvertisements on visibility loss

When a Web Bluetooth client calls watchAdvertisements(), if no active
discovery session exists, the request is placed in the
watch_advertisements_pending_clients_ queue.

If the page loses visibility, ClearAdvertisementClients() is called.
However, this function only cleared active clients and neglected
watch_advertisements_pending_clients_.

This CL ensures that all pending clients are aborted and cleared from
watch_advertisements_pending_clients_ during ClearAdvertisementClients(),
preventing visibility bypass.

Added a test to verify that pending requests are aborted when tab is hidden.

Bug: 496371586
Change-Id: I98945181d9eb132a4cec1869edfdb20ca269a2c9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7814024
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Rob Pitkin <robpitkin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1625796}
---

diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.cc b/content/browser/bluetooth/web_bluetooth_service_impl.cc
index 534f529..9a8a4ff1 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.cc
@@ -2391,6 +2391,13 @@
 void WebBluetoothServiceImpl::ClearAdvertisementClients() {
   scanning_clients_.clear();
   watch_advertisements_clients_.clear();
+
+  auto pending_clients = std::move(watch_advertisements_pending_clients_);
+  for (auto& pending_client : pending_clients) {
+    pending_client->RunCallback(
+        blink::mojom::WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED);
+  }
+
   allowed_scan_filters_.clear();
   accept_all_advertisements_ = false;
 }
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.h b/content/browser/bluetooth/web_bluetooth_service_impl.h
index 2b37b966..3798fb0 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.h
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.h
@@ -165,6 +165,8 @@
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            TwoWatchAdvertisementsReqFail);
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
+                           WatchAdvertisementsReqAbortedWhenTabHidden);
+  FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            SecWatchAdvertisementsReqAfterFirstSuccess);
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            WatchAdvertisementsFastPathChecksPermission);
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
index 5edbae8..309c147 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -1161,6 +1161,30 @@
 }
 
 TEST_F(WebBluetoothServiceImplTest,
+       WatchAdvertisementsReqAbortedWhenTabHidden) {
+  TestFuture<WebBluetoothResult> future;
+
+  const auto battery_device_id = AddTestDevice(battery_device_bundle());
+
+  mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothAdvertisementClient>
+      client_remote;
+
+  battery_device_bundle().advertisement_client().BindReceiver(
+      client_remote.InitWithNewEndpointAndPassReceiver());
+
+  // Install SUCCESS result for StartScanWithFilter
+  adapter_->SetStartScanWithFilterResult(
+      device::UMABluetoothDiscoverySessionOutcome::SUCCESS);
+
+  service_ptr_->WatchAdvertisementsForDevice(
+      battery_device_id, std::move(client_remote), future.GetCallback());
+
+  contents()->SetVisibilityAndNotifyObservers(Visibility::HIDDEN);
+
+  EXPECT_EQ(future.Get(), WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED);
+}
+
+TEST_F(WebBluetoothServiceImplTest,
        SecWatchAdvertisementsReqAfterFirstSuccess) {
   // Install SUCCESS result for StartScanWithFilter
   adapter_->SetStartScanWithFilterResult(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
index 5edbae8..309c147 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -1161,6 +1161,30 @@
 }
 
 TEST_F(WebBluetoothServiceImplTest,
+       WatchAdvertisementsReqAbortedWhenTabHidden) {
+  TestFuture<WebBluetoothResult> future;
+
+  const auto battery_device_id = AddTestDevice(battery_device_bundle());
+
+  mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothAdvertisementClient>
+      client_remote;
+
+  battery_device_bundle().advertisement_client().BindReceiver(
+      client_remote.InitWithNewEndpointAndPassReceiver());
+
+  // Install SUCCESS result for StartScanWithFilter
+  adapter_->SetStartScanWithFilterResult(
+      device::UMABluetoothDiscoverySessionOutcome::SUCCESS);
+
+  service_ptr_->WatchAdvertisementsForDevice(
+      battery_device_id, std::move(client_remote), future.GetCallback());
+
+  contents()->SetVisibilityAndNotifyObservers(Visibility::HIDDEN);
+
+  EXPECT_EQ(future.Get(), WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED);
+}
+
+TEST_F(WebBluetoothServiceImplTest,
        SecWatchAdvertisementsReqAfterFirstSuccess) {
   // Install SUCCESS result for StartScanWithFilter
   adapter_->SetStartScanWithFilterResult(
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Web Bluetooth watchAdvertisements visibility bypass via uncleared pending-client queue

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The Web Bluetooth implementation in Chrome fails to properly clear pending client requests and discovery session state when a tab is hidden. This allows a backgrounded tab to potentially continue receiving Bluetooth advertisement data from authorized devices, bypassing intended privacy protections. Two distinct bypass vectors exist due to incomplete state clearing in ClearAdvertisementClients().

Affected files:

  • content/browser/bluetooth/web_bluetooth_service_impl.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.h
  • content/browser/bluetooth/advertisement_client.cc

Estimated timestamp from git blame: 2026-03-11

Description

A potential privacy bypass vulnerability exists in the Web Bluetooth API implementation in Chrome. When a tab is backgrounded or loses focus, WebBluetoothServiceImpl::ClearAdvertisementClients() is called to stop the delivery of Bluetooth advertisements to the renderer. However, this function is incomplete, leading to two potential bypass vectors that could allow hidden tabs to receive proximity and tracking data (RSSI, manufacturer data, etc.) from previously authorized Bluetooth devices.

1. Pending Client Promotion Race

When device.watchAdvertisements() is called and no discovery session is currently active, the client is added to a pending queue (watch_advertisements_pending_clients_) and an asynchronous request to start a discovery session is sent to the platform.

If the tab is hidden before the platform responds, ClearAdvertisementClients() is triggered but fails to clear this pending queue (content/browser/bluetooth/web_bluetooth_service_impl.cc:2382). When OnStartDiscoverySessionForWatchAdvertisements() eventually receives the session handle, it iterates through the surviving pending queue and moves the clients into the active watch_advertisements_clients_ list (content/browser/bluetooth/web_bluetooth_service_impl.cc:1788). The checks performed during this promotion only verify device permission and Mojo pipe liveness, not the current visibility state of the tab, allowing a hidden tab to become an active listener.

2. Synchronous Session Reuse Bypass

ClearAdvertisementClients() also fails to reset the watch_advertisements_discovery_session_ handle or call MaybeStopDiscovery(). Consequently, the discovery session remains active in the browser process even after a tab is backgrounded.

If a hidden tab re-sends the watchAdvertisements IPC, WatchAdvertisementsForDeviceImpl() follows a synchronous fast-path (lines 1733-1741 in web_bluetooth_service_impl.cc) because the session already exists. This path directly inserts the client into the active list without any visibility checks, synchronously re-subscribing the hidden tab to the active session.

Once a client is in the active list via either vector, DeviceAdvertisementReceived() delivers advertisement events to the hidden tab via WatchAdvertisementsClient::SendEvent (content/browser/bluetooth/advertisement_client.cc:62). This enables persistent tracking of the user’s nearby Bluetooth devices without their knowledge or consent, even when the tab is not visible.

(Note: These are potential steps and analysis, as this agent cannot run code to verify.)

Suggested Steps to Reproduce

Vector 1 (Race):

  1. Grant an origin permission to a Bluetooth device via navigator.bluetooth.requestDevice().
  2. From the page, call device.watchAdvertisements().
  3. Immediately switch to another tab or minimize the browser window before the OS Bluetooth session starts.
  4. Observe that the hidden page potentially continues to receive advertisingevent data (RSSI, manufacturer data) despite the visibility change.

Vector 2 (Synchronous Reuse):

  1. Grant an origin permission to a Bluetooth device.
  2. From the page, call device.watchAdvertisements() and wait for the session to successfully start.
  3. Switch to another tab or minimize the browser window.
  4. While hidden, have the page call device.watchAdvertisements() again.
  5. Observe that the hidden page potentially receives advertisingevent data.

Suggested Fix

Update WebBluetoothServiceImpl::ClearAdvertisementClients() to comprehensively clear all advertisement-related state:

  1. Clear the watch_advertisements_pending_clients_ queue.
  2. Reset watch_advertisements_discovery_session_ and ble_scan_discovery_session_.
  3. Alternatively, ensure MaybeStopDiscovery() is called when visibility changes so that the underlying platform sessions are properly halted when no active visible clients remain.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker