diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.h b/content/browser/bluetooth/web_bluetooth_service_impl.h index 2b37b96677695..653b2bb33ad02 100644 --- a/content/browser/bluetooth/web_bluetooth_service_impl.h +++ b/content/browser/bluetooth/web_bluetooth_service_impl.h @@ -174,6 +174,12 @@ class CONTENT_EXPORT WebBluetoothServiceImpl EmulatedAdapterRemovalRestoresOriginalAdapter); FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest, ServiceDestroyedDuringAdapterAcquisition); + FRIEND_TEST_ALL_PREFIXES( + WebBluetoothServiceImplTest, + WatchAdvertisementsPendingClientSurvivesTabHidden); + FRIEND_TEST_ALL_PREFIXES( + WebBluetoothServiceImplTest, + WatchAdvertisementsFastPathBypassesTabHidden); #if PAIR_BLUETOOTH_ON_DEMAND() FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest, diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc index e11eb6ca3295d..a179795d3e299 100644 --- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc +++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc @@ -468,7 +468,10 @@ class FakeWebBluetoothAdvertisementClient // blink::mojom::WebBluetoothAdvertisementClient: void AdvertisingEvent( - blink::mojom::WebBluetoothAdvertisingEventPtr event) override {} + blink::mojom::WebBluetoothAdvertisingEventPtr event) override { + ++advertising_event_count_; + last_event_ = std::move(event); + } void BindReceiver(mojo::PendingAssociatedReceiver< blink::mojom::WebBluetoothAdvertisementClient> receiver) { @@ -478,14 +481,36 @@ class FakeWebBluetoothAdvertisementClient base::Unretained(this))); } + // Returns a remote bound to a dedicated message pipe so that calls on the + // AssociatedRemote (e.g. AdvertisingEvent()) can be made without the + // endpoint being associated with the WebBluetoothService pipe. Needed for + // tests that exercise DeviceAdvertisementReceived(). + mojo::PendingAssociatedRemote + BindNewEndpointAndPassDedicatedRemote() { + receiver_.reset(); + auto remote = receiver_.BindNewEndpointAndPassDedicatedRemote(); + receiver_.set_disconnect_handler( + base::BindOnce(&FakeWebBluetoothAdvertisementClient::OnConnectionError, + base::Unretained(this))); + return remote; + } + void OnConnectionError() { on_connection_error_called_ = true; } bool on_connection_error_called() { return on_connection_error_called_; } + int advertising_event_count() const { return advertising_event_count_; } + + const blink::mojom::WebBluetoothAdvertisingEventPtr& last_event() const { + return last_event_; + } + private: mojo::AssociatedReceiver receiver_{this}; bool on_connection_error_called_ = false; + int advertising_event_count_ = 0; + blink::mojom::WebBluetoothAdvertisingEventPtr last_event_; }; // A collection of Bluetooth objects which present related @@ -1451,4 +1476,159 @@ TEST_F(WebBluetoothServiceImplTestNewPermissionsBackend, EXPECT_CALL(*adapter_, StopScan).Times(1); } +// ----------------------------------------------------------------------------- +// POC: watchAdvertisements visibility bypass via uncleared pending-client queue. +// +// ClearAdvertisementClients() (invoked from OnVisibilityChanged / +// OnWebContentsLostFocus) clears watch_advertisements_clients_ but does NOT +// clear watch_advertisements_pending_clients_. If the tab is hidden while a +// StartDiscoverySession is in flight, the pending client survives and is +// promoted into the active list when the discovery session completes, +// allowing a hidden tab to receive BLE advertisement data (RSSI, name, etc.). +// +// These tests INTENTIONALLY assert the buggy behavior. They will FAIL once the +// bug is fixed. +// ----------------------------------------------------------------------------- + +// Primary variant: pending client survives ClearAdvertisementClients(). +TEST_F(WebBluetoothServiceImplTest, + WatchAdvertisementsPendingClientSurvivesTabHidden) { + adapter_->SetStartScanWithFilterResult( + device::UMABluetoothDiscoverySessionOutcome::SUCCESS); + + const auto device_id = AddTestDevice(battery_device_bundle()); + FakeWebBluetoothAdvertisementClient& client = + battery_device_bundle().advertisement_client(); + + // 1) Page calls device.watchAdvertisements(). With no existing discovery + // session, the client is pushed into watch_advertisements_pending_clients_ + // and StartDiscoverySession is issued asynchronously (FakeBluetoothAdapter + // posts the result to the current task runner). + TestFuture future; + service_ptr_->WatchAdvertisementsForDevice( + device_id, client.BindNewEndpointAndPassDedicatedRemote(), + future.GetCallback()); + + ASSERT_EQ(service_ptr_->watch_advertisements_pending_clients_.size(), 1u); + ASSERT_TRUE(service_ptr_->watch_advertisements_clients_.empty()); + + // 2) The user backgrounds the tab BEFORE the platform BLE stack responds. + // OnVisibilityChanged(HIDDEN) calls ClearAdvertisementClients(), which is + // the sole privacy enforcement for advertisement delivery. + contents()->SetVisibilityAndNotifyObservers(Visibility::HIDDEN); + + // BUG: the pending client queue was NOT cleared by the visibility change. + EXPECT_EQ(service_ptr_->watch_advertisements_pending_clients_.size(), 1u) + << "Expected: pending queue survives tab-hidden (this is the bug)."; + + // 3) The async StartDiscoverySession now completes. + // OnStartDiscoverySessionForWatchAdvertisements promotes the surviving + // pending client into watch_advertisements_clients_ — there is no + // visibility re-check, only Mojo-liveness + device-permission. + EXPECT_EQ(future.Get(), WebBluetoothResult::SUCCESS); + + ASSERT_EQ(contents()->GetVisibility(), Visibility::HIDDEN); + EXPECT_EQ(service_ptr_->watch_advertisements_clients_.size(), 1u) + << "Hidden tab now has an ACTIVE watch-advertisements client."; + EXPECT_FALSE(client.on_connection_error_called()); + + // 4) A nearby BLE device broadcasts. DeviceAdvertisementReceived iterates + // watch_advertisements_clients_ and delivers the event to the hidden tab. + service_ptr_->DeviceAdvertisementReceived( + battery_device_bundle().device().GetAddress(), + /*device_name=*/std::string("device with battery"), + /*advertisement_name=*/std::string("LeakedAdv"), + /*rssi=*/static_cast(-42), + /*tx_power=*/static_cast(7), + /*appearance=*/std::nullopt, + /*advertised_uuids=*/{}, + /*service_data_map=*/{}, + /*manufacturer_data_map=*/{}); + + // Flush the associated remote so AdvertisingEvent is delivered. + base::RunLoop().RunUntilIdle(); + + // BUG PROVEN: the hidden tab received an advertisingreceived event with + // RSSI / txPower despite the visibility privacy protection. + EXPECT_EQ(client.advertising_event_count(), 1) + << "Hidden tab received BLE advertisement data (privacy bypass)."; + ASSERT_TRUE(client.last_event()); + // rssi/tx_power are uint8 on the wire (bit-reinterpreted from int8). + EXPECT_TRUE(client.last_event()->rssi_is_set); + EXPECT_EQ(static_cast(client.last_event()->rssi), -42); + EXPECT_TRUE(client.last_event()->tx_power_is_set); + EXPECT_EQ(static_cast(client.last_event()->tx_power), 7); + EXPECT_EQ(client.last_event()->name, std::optional("LeakedAdv")); + + EXPECT_CALL(*adapter_, StopScan).Times(1); +} + +// Secondary variant: ClearAdvertisementClients() does not reset +// watch_advertisements_discovery_session_, so a hidden tab re-sending the +// WatchAdvertisementsForDevice IPC (e.g. on visibilitychange) hits the +// synchronous fast-path and is directly inserted into the active list. +TEST_F(WebBluetoothServiceImplTest, + WatchAdvertisementsFastPathBypassesTabHidden) { + adapter_->SetStartScanWithFilterResult( + device::UMABluetoothDiscoverySessionOutcome::SUCCESS); + + const auto device_id = AddTestDevice(battery_device_bundle()); + + // First watch: establishes watch_advertisements_discovery_session_. + FakeWebBluetoothAdvertisementClient first_client; + { + TestFuture future1; + service_ptr_->WatchAdvertisementsForDevice( + device_id, first_client.BindNewEndpointAndPassDedicatedRemote(), + future1.GetCallback()); + EXPECT_EQ(future1.Get(), WebBluetoothResult::SUCCESS); + } + ASSERT_TRUE(service_ptr_->watch_advertisements_discovery_session_); + ASSERT_EQ(service_ptr_->watch_advertisements_clients_.size(), 1u); + + // Tab is hidden. ClearAdvertisementClients() clears the active list but + // does NOT reset the discovery session nor call MaybeStopDiscovery(). + contents()->SetVisibilityAndNotifyObservers(Visibility::HIDDEN); + ASSERT_TRUE(service_ptr_->watch_advertisements_clients_.empty()); + EXPECT_TRUE(service_ptr_->watch_advertisements_discovery_session_) + << "Discovery session survives tab-hidden (enables fast-path bypass)."; + + // Hidden tab listens for visibilitychange and immediately re-calls + // device.watchAdvertisements(). With the discovery session still alive, + // WatchAdvertisementsForDeviceImpl takes the fast path and synchronously + // inserts the client into watch_advertisements_clients_ with no visibility + // check. + FakeWebBluetoothAdvertisementClient hidden_client; + TestFuture future2; + service_ptr_->WatchAdvertisementsForDevice( + device_id, hidden_client.BindNewEndpointAndPassDedicatedRemote(), + future2.GetCallback()); + EXPECT_EQ(future2.Get(), WebBluetoothResult::SUCCESS); + + ASSERT_EQ(contents()->GetVisibility(), Visibility::HIDDEN); + EXPECT_EQ(service_ptr_->watch_advertisements_clients_.size(), 1u) + << "Hidden tab re-registered an ACTIVE watch-advertisements client."; + + // Advertisement is delivered to the hidden tab. + service_ptr_->DeviceAdvertisementReceived( + battery_device_bundle().device().GetAddress(), + /*device_name=*/std::string("device with battery"), + /*advertisement_name=*/std::nullopt, + /*rssi=*/static_cast(-55), + /*tx_power=*/std::nullopt, + /*appearance=*/std::nullopt, + /*advertised_uuids=*/{}, + /*service_data_map=*/{}, + /*manufacturer_data_map=*/{}); + base::RunLoop().RunUntilIdle(); + + EXPECT_EQ(hidden_client.advertising_event_count(), 1) + << "Hidden tab received BLE advertisement data via fast-path bypass."; + ASSERT_TRUE(hidden_client.last_event()); + EXPECT_TRUE(hidden_client.last_event()->rssi_is_set); + EXPECT_EQ(static_cast(hidden_client.last_event()->rssi), -55); + + EXPECT_CALL(*adapter_, StopScan).Times(1); +} + } // namespace content