Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Bluetooth
DescriptionMemory corruption in Bluetooth
ComponentBluetooth
Sub-componentBluetooth
Bug ClassMemory Corruption
Tracker500536121
Fix commitac7f73dcbaab (chromium/src) +615/-62
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
content/browser/bluetooth/web_bluetooth_service_impl.cc
modified
erase_if
content/browser/bluetooth/web_bluetooth_service_impl.cc
modified
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_unittest.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.h
From ac7f73dcbaabb0f596d3a3b6b32b8c943dc3cddf Mon Sep 17 00:00:00 2001
From: Rob Pitkin <robpitkin@google.com>
Date: Tue, 15 Sep 2026 15:54:05 -0700
Subject: [PATCH] bluetooth: Clear Bluetooth GATT notify sessions on permission revocation

When Web Bluetooth permissions are revoked for an origin,
WebBluetoothServiceImpl::OnPermissionRevoked closes connections and
prunes advertisement clients, but previously retained active notify
sessions in characteristic_id_to_notify_session_ and deferred start
requests in characteristic_id_to_deferred_start_. If the device remained
physically connected by another frame/origin, incoming GATT
notifications continued to be forwarded across the open Mojo pipe.

This CL:

1. Prunes characteristic_id_to_notify_session_ and
   characteristic_id_to_deferred_start_ during OnPermissionRevoked for
   devices no longer permitted, failing outstanding callbacks with
   GATT_NOT_AUTHORIZED.
2. Caches device_id on notification session structures to avoid
   multi-hop map lookups during container pruning.
3. Explicitly halts notify sessions in OnStartNotifySessionSuccess if
   permission was revoked while the session start was in-flight.
4. Registers WebBluetoothServiceImpl as a FramePermissionObserver
   unconditionally across both permission backends.
5. Consolidates permission checks into HasDevicePermission() and
   verifies device permissions before completing pending GATT
   connections and before starting notification sessions.
6. Adds unit test coverage for permission revocation cleanup across
   active, in-flight, and deferred notification sessions, verifying
   behavior across both permission backends.

Bug: 500536121
Change-Id: Ifd97e3f24a8fd5299faf1de9e2ebc18253dd6fd8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8301506
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Rob Pitkin <robpitkin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698460}
---
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.cc b/content/browser/bluetooth/web_bluetooth_service_impl.cc
index e01901f..b7b9b355 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.cc
@@ -201,8 +201,11 @@
   DeferredStartNotificationData(
       mojo::PendingAssociatedRemote<
           blink::mojom::WebBluetoothCharacteristicClient> client,
-      RemoteCharacteristicStartNotificationsCallback callback)
-      : client(std::move(client)), callback(std::move(callback)) {}
+      RemoteCharacteristicStartNotificationsCallback callback,
+      blink::WebBluetoothDeviceId device_id)
+      : client(std::move(client)),
+        callback(std::move(callback)),
+        device_id(std::move(device_id)) {}
 
   ~DeferredStartNotificationData() = default;
 
@@ -212,6 +215,7 @@
   mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothCharacteristicClient>
       client;
   RemoteCharacteristicStartNotificationsCallback callback;
+  blink::WebBluetoothDeviceId device_id;
 };
 
 // static
@@ -339,12 +343,20 @@
 struct GATTNotifySessionAndCharacteristicClient {
   GATTNotifySessionAndCharacteristicClient(
       mojo::AssociatedRemote<blink::mojom::WebBluetoothCharacteristicClient>
-          client)
-      : characteristic_client(std::move(client)) {}
+          client,
+      WebBluetoothServiceImpl::RemoteCharacteristicStartNotificationsCallback
+          callback,
+      blink::WebBluetoothDeviceId device_id)
+      : characteristic_client(std::move(client)),
+        start_notifications_callback(std::move(callback)),
+        device_id(std::move(device_id)) {}
 
   std::unique_ptr<BluetoothGattNotifySession> gatt_notify_session;
   mojo::AssociatedRemote<blink::mojom::WebBluetoothCharacteristicClient>
       characteristic_client;
+  WebBluetoothServiceImpl::RemoteCharacteristicStartNotificationsCallback
+      start_notifications_callback;
+  blink::WebBluetoothDeviceId device_id;
 };
 
 // static
@@ -407,13 +419,10 @@
     PreventBackForwardCache();
   }
 
-  if (base::FeatureList::IsEnabled(
-          features::kWebBluetoothNewPermissionsBackend)) {
-    BluetoothDelegate* delegate =
-        GetContentClient()->browser()->GetBluetoothDelegate();
-    if (delegate) {
-      observer_.Observe(delegate);
-    }
+  BluetoothDelegate* delegate =
+      GetContentClient()->browser()->GetBluetoothDelegate();
+  if (delegate) {
+    observer_.Observe(delegate);
   }
 }
 
@@ -563,17 +572,56 @@
     return;
 
   std::set<blink::WebBluetoothDeviceId> permitted_ids;
-  for (const auto& device : delegate->GetPermittedDevices(&render_frame_host()))
+  for (const auto& device :
+       delegate->GetPermittedDevices(&render_frame_host())) {
     permitted_ids.insert(device->id);
+  }
 
   connected_devices_->CloseConnectionsToDevicesNotInList(permitted_ids);
 
+  std::erase_if(pending_connection_device_ids_, [&](const auto& device_id) {
+    return !permitted_ids.contains(device_id);
+  });
+
   std::erase_if(watch_advertisements_clients_,
                 [&](const std::unique_ptr<WatchAdvertisementsClient>& client) {
                   return !permitted_ids.contains(client->device_id());
                 });
 
+  std::vector<RemoteCharacteristicStartNotificationsCallback> callbacks_to_fail;
+
+  for (auto& [characteristic_id, session_data] :
+       characteristic_id_to_notify_session_) {
+    if (!permitted_ids.contains(session_data->device_id) &&
+        session_data->start_notifications_callback) {
+      callbacks_to_fail.push_back(
+          std::move(session_data->start_notifications_callback));
+    }
+  }
+  std::erase_if(characteristic_id_to_notify_session_, [&](const auto& pair) {
+    return !permitted_ids.contains(pair.second->device_id);
+  });
+
+  for (auto& [characteristic_id, deferred_queue] :
+       characteristic_id_to_deferred_start_) {
+    if (!deferred_queue.empty() &&
+        !permitted_ids.contains(deferred_queue.front()->device_id)) {
+      while (!deferred_queue.empty()) {
+        callbacks_to_fail.push_back(
+            std::move(deferred_queue.front()->callback));
+        deferred_queue.pop();
+      }
+    }
+  }
+  std::erase_if(characteristic_id_to_deferred_start_,
+                [](const auto& pair) { return pair.second.empty(); });
+
   MaybeStopDiscovery();
+
+  for (auto& callback : callbacks_to_fail) {
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::GATT_NOT_AUTHORIZED);
+  }
 }
 
 content::RenderFrameHost* WebBluetoothServiceImpl::GetRenderFrameHost() {
@@ -1319,6 +1367,14 @@
     RemoteCharacteristicStartNotificationsCallback callback) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
 
+  blink::WebBluetoothDeviceId device_id =
+      GetCharacteristicDeviceID(characteristic_instance_id);
+  if (!device_id.IsValid() || !HasDevicePermission(device_id)) {
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::GATT_NOT_AUTHORIZED);
+    return;
+  }
+
   const CacheQueryResult query_result =
       QueryCacheForCharacteristic(characteristic_instance_id);
   if (query_result.outcome != CacheQueryOutcome::kSuccess) {
@@ -1329,18 +1385,14 @@
 
   characteristic_id_to_notify_session_[characteristic_instance_id] =
       std::make_unique<GATTNotifySessionAndCharacteristicClient>(
-          std::move(client));
+          std::move(client), std::move(callback), std::move(device_id));
 
-  // TODO(crbug.com/40524549): Remove SplitOnceCallback() by updating
-  // the callee interface.
-  auto split_callback = base::SplitOnceCallback(std::move(callback));
   query_result.characteristic->StartNotifySession(
       base::BindOnce(&WebBluetoothServiceImpl::OnStartNotifySessionSuccess,
                      weak_ptr_factory_.GetWeakPtr(),
-                     std::move(split_callback.first)),
+                     characteristic_instance_id),
       base::BindOnce(&WebBluetoothServiceImpl::OnStartNotifySessionFailed,
                      weak_ptr_factory_.GetWeakPtr(),
-                     std::move(split_callback.second),
                      characteristic_instance_id));
 }
 
@@ -1369,8 +1421,9 @@
       // awaiting a notify session. Defer this start, and continue once the
       // in-flight start has completed.
       characteristic_id_to_deferred_start_[characteristic_instance_id].emplace(
-          std::make_unique<DeferredStartNotificationData>(std::move(client),
-                                                          std::move(callback)));
+          std::make_unique<DeferredStartNotificationData>(
+              std::move(client), std::move(callback),
+              notification_client->device_id));
       return;
     }
     if (notification_client->gatt_notify_session->IsActive()) {
@@ -1442,9 +1495,10 @@
 
   auto notify_session_iter =
       characteristic_id_to_notify_session_.find(characteristic_instance_id);
-  if (notify_session_iter == characteristic_id_to_notify_session_.end()) {
-    // If the frame hasn't subscribed to notifications before we just
-    // run the callback.
+  if (notify_session_iter == characteristic_id_to_notify_session_.end() ||
+      !notify_session_iter->second->gatt_notify_session) {
+    // If the frame hasn't subscribed to notifications before or the start
+    // request is still in flight, we just run the callback.
     std::move(callback).Run();
     return;
   }
@@ -1831,7 +1885,7 @@
   std::move(callback).Run(std::move(web_bluetooth_devices));
 }
 
-bool WebBluetoothServiceImpl::HasWatchAdvertisementsPermission(
+bool WebBluetoothServiceImpl::HasDevicePermission(
     const blink::WebBluetoothDeviceId& device_id) {
   if (base::FeatureList::IsEnabled(
           features::kWebBluetoothNewPermissionsBackend)) {
@@ -1861,7 +1915,7 @@
       /*service=*/this, std::move(client_remote), std::move(device_id),
       std::move(callback));
   if (watch_advertisements_discovery_session_) {
-    if (!HasWatchAdvertisementsPermission(pending_client->device_id())) {
+    if (!HasDevicePermission(pending_client->device_id())) {
       pending_client->RunCallback(
           blink::mojom::WebBluetoothResult::NOT_ALLOWED_TO_ACCESS_ANY_SERVICE);
       return;
@@ -1908,7 +1962,7 @@
       continue;
     }
 
-    if (!HasWatchAdvertisementsPermission(pending_client->device_id())) {
+    if (!HasDevicePermission(pending_client->device_id())) {
       pending_client->RunCallback(
           blink::mojom::WebBluetoothResult::NOT_ALLOWED_TO_ACCESS_ANY_SERVICE);
       continue;
@@ -2153,28 +2207,43 @@
 }
 
 void WebBluetoothServiceImpl::OnStartNotifySessionSuccess(
-    RemoteCharacteristicStartNotificationsCallback callback,
+    const std::string& characteristic_instance_id,
     std::unique_ptr<BluetoothGattNotifySession> notify_session) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  std::move(callback).Run(blink::mojom::WebBluetoothResult::SUCCESS);
-  std::string characteristic_id = notify_session->GetCharacteristicIdentifier();
-  auto iter = characteristic_id_to_notify_session_.find(characteristic_id);
+  auto iter =
+      characteristic_id_to_notify_session_.find(characteristic_instance_id);
 
-  if (iter == characteristic_id_to_notify_session_.end())
+  if (iter == characteristic_id_to_notify_session_.end()) {
+    // If the session is no longer in |characteristic_id_to_notify_session_|,
+    // permission was revoked while StartNotifySession was in-flight.
+    // Explicitly stop the session to ensure notifications are halted on the
+    // platform adapter immediately, rather than relying solely on the session
+    // destructor.
+    if (notify_session) {
+      notify_session->Stop(base::DoNothing());
+    }
     return;
+  }
+
   // Saving the BluetoothGattNotifySession keeps notifications active.
   iter->second->gatt_notify_session = std::move(notify_session);
 
+  RemoteCharacteristicStartNotificationsCallback callback =
+      std::move(iter->second->start_notifications_callback);
+  if (callback) {
+    std::move(callback).Run(blink::mojom::WebBluetoothResult::SUCCESS);
+  }
+
   // Continue any deferred notification starts.
   auto deferred_iter =
-      characteristic_id_to_deferred_start_.find(characteristic_id);
+      characteristic_id_to_deferred_start_.find(characteristic_instance_id);
   if (deferred_iter != characteristic_id_to_deferred_start_.end()) {
     base::queue<std::unique_ptr<DeferredStartNotificationData>> deferral_queue =
         std::move(deferred_iter->second);
     characteristic_id_to_deferred_start_.erase(deferred_iter);
     while (!deferral_queue.empty()) {
       RemoteCharacteristicStartNotifications(
-          characteristic_id, std::move(deferral_queue.front()->client),
+          characteristic_instance_id, std::move(deferral_queue.front()->client),
           std::move(deferral_queue.front()->callback));
       deferral_queue.pop();
     }
@@ -2182,34 +2251,20 @@
 }
 
 void WebBluetoothServiceImpl::OnStartNotifySessionFailed(
-    RemoteCharacteristicStartNotificationsCallback callback,
     const std::string& characteristic_instance_id,
     GattErrorCode error_code) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   auto iter =
       characteristic_id_to_notify_session_.find(characteristic_instance_id);
-  mojo::AssociatedRemote<blink::mojom::WebBluetoothCharacteristicClient> client;
-  if (iter != characteristic_id_to_notify_session_.end()) {
-    client = std::move(iter->second->characteristic_client);
-    characteristic_id_to_notify_session_.erase(iter);
+  if (iter == characteristic_id_to_notify_session_.end()) {
+    return;
   }
 
-#if PAIR_BLUETOOTH_ON_DEMAND()
-  if (error_code == GattErrorCode::kNotAuthorized && client) {
-    BluetoothDevice* device =
-        GetCachedDevice(GetCharacteristicDeviceID(characteristic_instance_id));
-    if (device && !device->IsPaired()) {
-      // Initiate pairing. See (Secure Characteristics) in README.md for more
-      // information.
-      pairing_manager_->PairForCharacteristicStartNotifications(
-          characteristic_instance_id, std::move(client), std::move(callback));
-      return;
-    }
-  }
-#endif  // PAIR_BLUETOOTH_ON_DEMAND()
-
-  std::move(callback).Run(TranslateGATTErrorAndRecord(
-      error_code, UMAGATTOperation::kStartNotifications));
+  mojo::AssociatedRemote<blink::mojom::WebBluetoothCharacteristicClient>
+      client = std::move(iter->second->characteristic_client);
+  RemoteCharacteristicStartNotificationsCallback callback =
+      std::move(iter->second->start_notifications_callback);
+  characteristic_id_to_notify_session_.erase(iter);
 
   // Fail any deferred notification starts blocked on this one.
   auto deferred_iter =
@@ -2227,6 +2282,25 @@
       deferral_queue.pop();
     }
   }
+
+#if PAIR_BLUETOOTH_ON_DEMAND()
+  if (error_code == GattErrorCode::kNotAuthorized && client) {
+    BluetoothDevice* device =
+        GetCachedDevice(GetCharacteristicDeviceID(characteristic_instance_id));
+    if (device && !device->IsPaired()) {
+      // Initiate pairing. See (Secure Characteristics) in README.md for more
+      // information.
+      pairing_manager_->PairForCharacteristicStartNotifications(
+          characteristic_instance_id, std::move(client), std::move(callback));
+      return;
+    }
+  }
+#endif  // PAIR_BLUETOOTH_ON_DEMAND()
+
+  if (callback) {
+    std::move(callback).Run(TranslateGATTErrorAndRecord(
+        error_code, UMAGATTOperation::kStartNotifications));
+  }
 }
 
 void WebBluetoothServiceImpl::OnStopNotifySessionComplete(
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.h b/content/browser/bluetooth/web_bluetooth_service_impl.h
index c9a45b39..f4badc2 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.h
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.h
@@ -343,11 +343,6 @@
           blink::mojom::WebBluetoothAdvertisementClient> client_remote,
       WatchAdvertisementsForDeviceCallback callback,
       scoped_refptr<device::BluetoothAdapter> adapter);
-  // Returns true if the device identified by |device_id| has permission to be
-  // watched for advertisements. Checks either the new permissions backend
-  // (BluetoothDelegate) or the legacy allowed_devices list.
-  bool HasWatchAdvertisementsPermission(
-      const blink::WebBluetoothDeviceId& device_id);
   void OnStartDiscoverySessionForWatchAdvertisements(
       std::unique_ptr<device::BluetoothDiscoverySession> session);
   void OnDiscoverySessionErrorForWatchAdvertisements();
@@ -402,10 +397,9 @@
 
   // Callbacks for BluetoothRemoteGattCharacteristic::StartNotifySession.
   void OnStartNotifySessionSuccess(
-      RemoteCharacteristicStartNotificationsCallback callback,
+      const std::string& characteristic_instance_id,
       std::unique_ptr<device::BluetoothGattNotifySession> notify_session);
   void OnStartNotifySessionFailed(
-      RemoteCharacteristicStartNotificationsCallback callback,
       const std::string& characteristic_instance_id,
       device::BluetoothGattService::GattErrorCode error_code);
 
... (file diff truncated)
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 be04cdd..1fd65ad 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -10,6 +10,7 @@
 #include <vector>
 
 #include "base/memory/raw_ptr.h"
+#include "base/observer_list.h"
 #include "base/task/single_thread_task_runner.h"
 #include "base/test/bind.h"
 #include "base/test/gmock_callback_support.h"
@@ -32,6 +33,7 @@
 #include "device/bluetooth/test/mock_bluetooth_adapter.h"
 #include "device/bluetooth/test/mock_bluetooth_device.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_characteristic.h"
+#include "device/bluetooth/test/mock_bluetooth_gatt_connection.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_descriptor.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_notify_session.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_service.h"
@@ -42,6 +44,7 @@
 #include "mojo/public/cpp/test_support/test_utils.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/features.h"
 #include "third_party/blink/public/mojom/bluetooth/web_bluetooth.mojom.h"
 
 namespace content {
@@ -54,6 +57,9 @@
 using ::blink::mojom::WebBluetoothRemoteGATTCharacteristicPtr;
 using ::blink::mojom::WebBluetoothRemoteGATTServicePtr;
 using ::blink::mojom::WebBluetoothResult;
+using ::blink::mojom::WebBluetoothService;
+using RemoteCharacteristicStartNotificationsCallback = ::blink::mojom::
+    WebBluetoothService::RemoteCharacteristicStartNotificationsCallback;
 using ::device::BluetoothDevice;
 using ::device::BluetoothGattService;
 using ::device::BluetoothRemoteGattCharacteristic;
@@ -198,6 +204,65 @@
   mojo::AssociatedReceiver<WebBluetoothCharacteristicClient> receiver_{this};
 };
 
+class RecordingWebBluetoothCharacteristicClient
+    : public WebBluetoothCharacteristicClient {
+ public:
+  RecordingWebBluetoothCharacteristicClient() = default;
+  ~RecordingWebBluetoothCharacteristicClient() override = default;
+
+  mojo::PendingAssociatedRemote<WebBluetoothCharacteristicClient>
+  BindNewEndpointClientAndPassRemote() {
+    receiver_.reset();
+    return receiver_.BindNewEndpointAndPassDedicatedRemote();
+  }
+
+  const std::vector<std::vector<uint8_t>>& received_values() const {
+    return received_values_;
+  }
+
+  void SetValueChangeCallback(base::RepeatingClosure callback) {
+    value_change_callback_ = std::move(callback);
+  }
+
+ protected:
+  // WebBluetoothCharacteristicClient implementation:
+  void RemoteCharacteristicValueChanged(
+      base::span<const uint8_t> value) override {
+    received_values_.emplace_back(value.begin(), value.end());
+    if (value_change_callback_) {
+      value_change_callback_.Run();
+    }
+  }
+
+ private:
+  mojo::AssociatedReceiver<WebBluetoothCharacteristicClient> receiver_{this};
+  std::vector<std::vector<uint8_t>> received_values_;
+  base::RepeatingClosure value_change_callback_;
+};
+
+class FakeWebBluetoothServerClient
+    : public blink::mojom::WebBluetoothServerClient {
+ public:
+  FakeWebBluetoothServerClient() = default;
+  ~FakeWebBluetoothServerClient() override = default;
+
+  mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothServerClient>
+  BindNewEndpointAndPassRemote() {
+    receiver_.reset();
+    return receiver_.BindNewEndpointAndPassDedicatedRemote();
+  }
+
+  bool disconnected() const { return disconnected_; }
+
+ private:
+  // blink::mojom::WebBluetoothServerClient:
+  void GATTServerDisconnected() override { disconnected_ = true; }
+
+  mojo::AssociatedReceiver<blink::mojom::WebBluetoothServerClient> receiver_{
+      this};
+  bool disconnected_ = false;
+};
+
 class FakeBluetoothAdapter : public device::MockBluetoothAdapter {
  public:
   FakeBluetoothAdapter() = default;
@@ -345,6 +410,21 @@
   TestBluetoothDelegate& operator=(const TestBluetoothDelegate&) = delete;
 
   // BluetoothDelegate:
+  void AddFramePermissionObserver(FramePermissionObserver* observer) override {
+    observer_list_.AddObserver(observer);
+  }
+
+  void RemoveFramePermissionObserver(
+      FramePermissionObserver* observer) override {
+    observer_list_.RemoveObserver(observer);
+  }
+
+  void RevokePermission(const url::Origin& origin) {
+    for (auto& observer : observer_list_) {
+      observer.OnPermissionRevoked(origin);
+    }
+  }
+
   std::unique_ptr<BluetoothScanningPrompt> ShowBluetoothScanningPrompt(
       RenderFrameHost* frame,
       const BluetoothScanningPrompt::EventHandler& event_handler) override {
@@ -362,6 +442,26 @@
     std::move(callback).Run(PairPromptResult(PairPromptStatus::kCancelled));
   }
 
+  blink::WebBluetoothDeviceId GetWebBluetoothDeviceId(
+      RenderFrameHost* frame,
+      const std::string& device_address) override {
+    auto it = address_to_id_map_.find(device_address);
+    if (it != address_to_id_map_.end()) {
+      return it->second;
+    }
+    return blink::WebBluetoothDeviceId();
+  }
+
+  std::string GetDeviceAddress(
+      RenderFrameHost* frame,
+      const blink::WebBluetoothDeviceId& device_id) override {
+    auto it = id_to_address_map_.find(device_id);
+    if (it != id_to_address_map_.end()) {
+      return it->second;
+    }
+    return std::string();
+  }
+
   bool HasDevicePermission(
       RenderFrameHost* frame,
       const blink::WebBluetoothDeviceId& device_id) override {
@@ -372,6 +472,38 @@
     has_device_permission_ = value;
   }
 
+  bool IsAllowedToAccessService(RenderFrameHost* frame,
+                                const blink::WebBluetoothDeviceId& device_id,
+                                const device::BluetoothUUID& service) override {
+    return has_device_permission_;
+  }
+
+  bool IsAllowedToAccessAtLeastOneService(
+      RenderFrameHost* frame,
+      const blink::WebBluetoothDeviceId& device_id) override {
+    return has_device_permission_;
+  }
+
+  std::vector<blink::mojom::WebBluetoothDevicePtr> GetPermittedDevices(
+      RenderFrameHost* frame) override {
+    if (!has_device_permission_) {
+      return {};
+    }
+    std::vector<blink::mojom::WebBluetoothDevicePtr> devices;
+    for (const auto& [address, id] : address_to_id_map_) {
+      auto device = blink::mojom::WebBluetoothDevice::New();
+      device->id = id;
+      devices.push_back(std::move(device));
+    }
+    return devices;
+  }
+
+  void AddDevice(const std::string& address,
+                 const blink::WebBluetoothDeviceId& device_id) {
+    address_to_id_map_[address] = device_id;
+    id_to_address_map_[device_id] = address;
+  }
+
   bool MayUseBluetooth(RenderFrameHost* frame) override {
     return may_use_bluetooth_;
   }
@@ -405,6 +537,9 @@
       AllowWebBluetoothResult::kAllow;
   raw_ptr<FakeBluetoothScanningPrompt, AcrossTasksDanglingUntriaged> prompt_ =
       nullptr;
+  base::ObserverList<FramePermissionObserver> observer_list_;
+  std::map<std::string, blink::WebBluetoothDeviceId> address_to_id_map_;
+  std::map<blink::WebBluetoothDeviceId, std::string> id_to_address_map_;
 };
 
 class TestContentBrowserClient : public ContentBrowserClient {
@@ -702,8 +837,11 @@
     device_options->accept_all_devices = true;
     device_options->optional_services.push_back(
         device_bundle.service().GetUUID());
-    return service_ptr_->allowed_devices().AddDevice(
+    blink::WebBluetoothDeviceId id = service_ptr_->allowed_devices().AddDevice(
         device_bundle.device().GetAddress(), device_options);
+    browser_client_.bluetooth_delegate()->AddDevice(
+        device_bundle.device().GetAddress(), id);
+    return id;
   }
 
   void DeleteService() {
@@ -720,6 +858,56 @@
     service_ptr_->ForgetDevice(device_id, std::move(callback));
   }
 
+  void RemoteServerConnect(
+      const blink::WebBluetoothDeviceId& device_id,
+      mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothServerClient>
+          client,
+      blink::mojom::WebBluetoothService::RemoteServerConnectCallback callback) {
+    service_ptr_->RemoteServerConnect(device_id, std::move(client),
+                                      std::move(callback));
+  }
+
+  void RemoteCharacteristicStartNotifications(
+      const std::string& characteristic_instance_id,
+      mojo::PendingAssociatedRemote<
+          blink::mojom::WebBluetoothCharacteristicClient> client,
+      blink::mojom::WebBluetoothService::
+          RemoteCharacteristicStartNotificationsCallback callback) {
+    service_ptr_->RemoteCharacteristicStartNotifications(
+        characteristic_instance_id, std::move(client), std::move(callback));
+  }
+
+  void RemoteCharacteristicStopNotifications(
+      const std::string& characteristic_instance_id,
+      blink::mojom::WebBluetoothService::
+          RemoteCharacteristicStopNotificationsCallback callback) {
+    service_ptr_->RemoteCharacteristicStopNotifications(
+        characteristic_instance_id, std::move(callback));
+  }
+
+  void RemoteCharacteristicStartNotificationsInternal(
+      const std::string& characteristic_instance_id,
+      mojo::AssociatedRemote<blink::mojom::WebBluetoothCharacteristicClient>
+          client,
+      blink::mojom::WebBluetoothService::
+          RemoteCharacteristicStartNotificationsCallback callback) {
+    service_ptr_->RemoteCharacteristicStartNotificationsInternal(
+        characteristic_instance_id, std::move(client), std::move(callback));
+  }
+
+  void GattCharacteristicValueChanged(
+      device::BluetoothAdapter* adapter,
+      device::BluetoothRemoteGattCharacteristic* characteristic,
+      const std::vector<uint8_t>& value) {
+    service_ptr_->GattCharacteristicValueChanged(adapter, characteristic,
+                                                 value);
+  }
+
+  bool HasNotifySession(const std::string& characteristic_instance_id) {
+    return service_ptr_->characteristic_id_to_notify_session_.contains(
+        characteristic_instance_id);
+  }
+
   std::string GetAllowedDeviceAddress(
       const blink::WebBluetoothDeviceId& device_id) {
     return service_ptr_->allowed_devices().GetDeviceAddress(device_id);
@@ -1004,8 +1192,64 @@
   // tab closure while the pairing request is in progress.
   DeleteService();
 }
+
+TEST_F(WebBluetoothServiceImplTest,
+       DeferredStartsFailOnNotAuthorizedWithPairing) {
+  RegisterTestCharacteristic();
+  FakeBluetoothCharacteristic& test_characteristic =
+      battery_device_bundle().characteristic();
+  test_characteristic.DeferNextStartNotification(
+      device::BluetoothGattService::GattErrorCode::kNotAuthorized);
+
+  auto pairing_manager =
+      std::make_unique<NiceMock<MockWebBluetoothPairingManager>>();
+  EXPECT_CALL(*pairing_manager, PairForCharacteristicStartNotifications(
+                                    test_characteristic.GetIdentifier(), _, _))
+      .Times(1);
+  service_ptr_->SetPairingManagerForTesting(std::move(pairing_manager));
+
+  RecordingWebBluetoothCharacteristicClient client1;
+  base::test::TestFuture<WebBluetoothResult> start_future1;
+  RemoteCharacteristicStartNotifications(
+      test_characteristic.GetIdentifier(),
+      client1.BindNewEndpointClientAndPassRemote(),
+      start_future1.GetCallback());
+
+  RecordingWebBluetoothCharacteristicClient client2;
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

GATT Notification Leak via Incomplete Permission Revocation Cleanup

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 without the security team.

Overview: When Web Bluetooth permissions are revoked, WebBluetoothServiceImpl fails to clear active GATT notification sessions. If the device remains physically connected due to another origin, a compromised renderer can ignore disconnect messages and continue receiving sensitive Bluetooth data. This bypasses the permission revocation entirely.

Affected files:

  • content/browser/bluetooth/web_bluetooth_service_impl.cc

Estimated timestamp from git blame: 2026-01-06

Summary

A potential vulnerability exists in WebBluetoothServiceImpl::OnPermissionRevoked where it fails to completely tear down the internal state associated with GATT notifications. Specifically, it omits clearing the characteristic_id_to_notify_session_ and characteristic_id_to_service_id_ maps.

If a user revokes Bluetooth permissions for a site, but the physical connection to the peripheral remains active (e.g., because another site or app is connected to it), the browser continues to forward GATT notification updates over the established Mojo pipe to the revoked origin’s renderer. While a normal renderer drops these updates due to disconnect events, a compromised renderer can ignore the disconnect and continue reading the leaked device data, bypassing the permission boundary.

Technical Details

When a user revokes a Bluetooth permission (with the kWebBluetoothNewPermissionsBackend feature enabled), WebBluetoothServiceImpl::OnPermissionRevoked is invoked. It correctly closes the frame’s logical connection to the device via connected_devices_->CloseConnectionsToDevicesNotInList(...). This removes the frame’s BluetoothGattConnection handle and sends a GATTServerDisconnected() IPC to the renderer.

However, it fails to clear the characteristic and notification tracking maps:

void WebBluetoothServiceImpl::OnPermissionRevoked(const url::Origin& origin) {
  // ...
  connected_devices_->CloseConnectionsToDevicesNotInList(permitted_ids);
  // MISSING: Cleanup of characteristic_id_to_notify_session_, characteristic_id_to_service_id_, etc.
  // ...
}

When the peripheral sends a notification, it is received by WebBluetoothServiceImpl::GattCharacteristicValueChanged. This “push” path relies exclusively on the stale internal maps to decide whether to forward the data:

  1. It checks characteristic_id_to_service_id_.
  2. It posts a task to NotifyCharacteristicValueChanged.
  3. NotifyCharacteristicValueChanged checks characteristic_id_to_notify_session_.
  4. It sends the data via iter->second->characteristic_client->RemoteCharacteristicValueChanged(value).

Crucially, this push path does not perform dynamic permission checks (like IsAllowedToAccessService), meaning the data is sent directly to the renderer’s Mojo endpoint despite the revocation.

Potential Attack Scenario

  1. An attacker-controlled site (evil.example) and a benign site (good.example) both connect to the same Bluetooth device.
  2. The attacker site discovers characteristics and starts GATT notifications.
  3. The user revokes Bluetooth permission for evil.example via Chrome settings.
  4. WebBluetoothServiceImpl::OnPermissionRevoked runs, but leaves evil.example’s notification sessions intact in the browser process.
  5. Because good.example is still connected, the underlying physical connection to the device stays alive.
  6. An attacker exploiting a compromised renderer for evil.example ignores the incoming GATTServerDisconnected Mojo message.
  7. The peripheral broadcasts a characteristic value change.
  8. The browser forwards the data to the compromised renderer via RemoteCharacteristicValueChanged, allowing the attacker to continue monitoring the device.

Suggested Fix

Update WebBluetoothServiceImpl::OnPermissionRevoked to explicitly clear the state associated with the revoked devices. This could involve stopping the active notification sessions and clearing entries from characteristic_id_to_notify_session_, characteristic_id_to_service_id_, descriptor_id_to_characteristic_id_, and service_id_to_device_address_ for any characteristics belonging to devices that are no longer permitted.

Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234


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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker