CVE-2026-113131
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/bluetooth/web_bluetooth_service_impl.cc |
modified | |
erase_ifcontent/browser/bluetooth/web_bluetooth_service_impl.cc |
modified | |
forcontent/browser/bluetooth/web_bluetooth_service_impl.cc |
modified |
Files Changed
content/browser/bluetooth/web_bluetooth_service_impl.cccontent/browser/bluetooth/web_bluetooth_service_impl_unittest.cccontent/browser/bluetooth/web_bluetooth_service_impl.h
Patch
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)
Regression Test / PoC
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)
Original Bug Report
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:
- It checks
characteristic_id_to_service_id_. - It posts a task to
NotifyCharacteristicValueChanged. NotifyCharacteristicValueChangedcheckscharacteristic_id_to_notify_session_.- 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
- An attacker-controlled site (
evil.example) and a benign site (good.example) both connect to the same Bluetooth device. - The attacker site discovers characteristics and starts GATT notifications.
- The user revokes Bluetooth permission for
evil.examplevia Chrome settings. WebBluetoothServiceImpl::OnPermissionRevokedruns, but leavesevil.example’s notification sessions intact in the browser process.- Because
good.exampleis still connected, the underlying physical connection to the device stays alive. - An attacker exploiting a compromised renderer for
evil.exampleignores the incomingGATTServerDisconnectedMojo message. - The peripheral broadcasts a characteristic value change.
- 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.