CVE-2026-79104
Overview
Background
- `FrameSensorProviderProxy`
- the browser-side, per-document (
DocumentUserData) broker that receives a renderer’sblink::mojom::WebSensorProvider::GetSensorrequest, checks theSENSORSpermission, and forwards the request to the device service throughWebContentsSensorProviderProxy. - `SensorConnectionWatcher` (old)
- a pre-patch Mojo interface that the browser implemented and handed to the device service as a
PendingRemote; the browser could only learn of disconnects through it and had no channel to tell the device service to pause a live sensor. - `SensorClientController` (new)
- a Mojo interface the device-service
SensorImplimplements (SensorImpl::SensorClientControllerImpl); the browser now keeps theRemoteend in amojo::RemoteSet(client_controllers_) and can callSuspend()/Resume(), or drop the remote to sever the sensor. - Document activity / visibility
RenderFrameHost::IsActive()is false for documents that are, for example, in the back/forward cache, andWebContents::GetVisibility()returnsHIDDENfor backgrounded tabs; the patch uses both to decide whether a sensor session should deliver readings.
Root Cause Analysis
Before the patch, once FrameSensorProviderProxy::GetSensor had granted a sensor, the browser did not supervise the session any further. The only back-channel was a SensorConnectionWatcher receiver, which the browser implemented and which simply reported disconnects (OnSensorDisconnect). Nothing in FrameSensorProviderProxy watched WebContents visibility or the frame’s lifecycle state. A sensor stream that was authorized while the document was active and visible therefore kept running after the tab was hidden or the document became inactive. Revoking the permission only cut the connection when the kSeverSensorConnectionsOnPermissionRevocation feature was on, because that feature gated the SubscribeToPermissionResultChange call.
The patch reverses the direction of the control pipe: the browser now holds a SensorClientController remote per granted sensor, inherits WebContentsObserver, and on OnVisibilityChanged / RenderFrameHostStateChanged calls UpdateSensorSessionControllers(). That function sends Suspend() whenever ShouldSuspendSensors() holds (no web_contents(), !render_frame_host().IsActive(), or Visibility::HIDDEN) and sends Resume() otherwise. Sessions can also start suspended (initially_suspended, under kSensorPrivacyMitigations), and the permission-change subscription is now made whenever connections are tracked. The device-side half of the mechanism (sensor_impl.cc, sensor_provider_impl.cc, the mojoms) is truncated in the provided diff, so this analysis does not cover how SensorImpl enforces suspension.
GetSensor time, and then had no means to withdraw that authorization when the conditions that justified it ended: the page was no longer visible or active, or the permission had changed. The fix gives the browser a controller pipe it owns (SensorClientController), so it can suspend, resume or sever every live sensor session based on visibility, lifecycle and permission state.Attack Path
- Obtain a sensor
A page calls the Generic Sensor API (for example, the accelerometer).
FrameSensorProviderProxy::GetSensorseesGRANTED(or gets it through the prompt flow) and returnsSensorInitParams, as in the regression test. - Leave the authorized state
The user switches tabs (
WebContents::WasHidden()), or the document moves into an inactive lifecycle state such as BFCache. Before the patch, nothing observed either transition. - Keep receiving readings
The device-service sensor session stays live, so the hidden or inactive document keeps getting motion or other sensor data. The new tests
GetSensor_VisibilityChanged_SuspendsAndResumesandGetSensor_InitiallyHidden_StartsSuspendedassert that this is now suspended. - Use the data Background motion or orientation readings can be used for side-channel inference, for example about keystrokes or device handling (inferred; the patch shows only that delivery continued).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/generic_sensor/frame_sensor_provider_proxy.cc |
modified | |
forcontent/browser/generic_sensor/frame_sensor_provider_proxy.cc |
modified | |
SensorImplservices/device/generic_sensor/sensor_impl.cc |
modified |
Files Changed
content/browser/generic_sensor/frame_sensor_provider_proxy.cccontent/browser/generic_sensor/frame_sensor_provider_proxy.hcontent/browser/generic_sensor/frame_sensor_provider_proxy_unittest.cccontent/browser/generic_sensor/web_contents_sensor_provider_proxy.cccontent/browser/generic_sensor/web_contents_sensor_provider_proxy.hcontent/browser/generic_sensor/web_contents_sensor_provider_proxy_unittest.ccdevice/vr/orientation/orientation_device.ccdevice/vr/test/fake_sensor_provider.ccdevice/vr/test/fake_sensor_provider.hservices/device/generic_sensor/generic_sensor_service_unittest.ccservices/device/generic_sensor/sensor_impl.ccservices/device/generic_sensor/sensor_impl.hservices/device/generic_sensor/sensor_provider_impl.ccservices/device/generic_sensor/sensor_provider_impl.hservices/device/public/cpp/device_features.ccservices/device/public/cpp/device_features.hservices/device/public/cpp/test/fake_sensor_and_provider.ccservices/device/public/cpp/test/fake_sensor_and_provider.hservices/device/public/mojom/sensor.mojomservices/device/public/mojom/sensor_provider.mojomthird_party/blink/renderer/modules/sensor/sensor_test_utils.cc
Audit Directions
- Grant-once capability streamsLook at other browser-brokered streams that are authorized once at creation (Generic Sensor, Geolocation
watchPosition, device orientation, media capture, HID or serial reads) and check whether they are suspended or severed when the document is hidden, frozen, enters BFCache, or loses permission. - Feature-gated revocationLook for permission-revocation or privacy enforcement that runs only behind a feature flag, as
kSeverSensorConnectionsOnPermissionRevocationdid; when the flag is off, the authorization check silently degrades. - Inverted control pipesCheck whether the trusted process holds a controlling
Remoteto the service, or only a passive watcher; a watcher-only design cannot enforce a policy change on an already-live session.
Patch
diff --git a/content/browser/generic_sensor/frame_sensor_provider_proxy.cc b/content/browser/generic_sensor/frame_sensor_provider_proxy.cc
index d992e1f..c06f9f0 100644
--- a/content/browser/generic_sensor/frame_sensor_provider_proxy.cc
+++ b/content/browser/generic_sensor/frame_sensor_provider_proxy.cc
@@ -67,8 +67,9 @@
FrameSensorProviderProxy::FrameSensorProviderProxy(
RenderFrameHost* render_frame_host)
- : DocumentUserData<FrameSensorProviderProxy>(render_frame_host) {
- watcher_receivers_.set_disconnect_handler(
+ : DocumentUserData<FrameSensorProviderProxy>(render_frame_host),
+ WebContentsObserver(WebContents::FromRenderFrameHost(render_frame_host)) {
+ client_controllers_.set_disconnect_handler(
base::BindRepeating(&FrameSensorProviderProxy::OnSensorDisconnect,
weak_factory_.GetWeakPtr()));
}
@@ -84,7 +85,7 @@
// Notify the delegate for each active connection that is being cleared.
auto* delegate = GetContentClient()->browser()->GetSensorDelegate();
if (delegate) {
- for (size_t i = 0; i < watcher_receivers_.size(); ++i) {
+ for (size_t i = 0; i < client_controllers_.size(); ++i) {
delegate->OnSensorStopped(&render_frame_host());
}
}
@@ -97,6 +98,7 @@
void FrameSensorProviderProxy::OnMojoConnectionError() {
receiver_set_.Clear();
+ client_controllers_.Clear();
}
void FrameSensorProviderProxy::GetSensor(device::mojom::SensorType type,
@@ -144,7 +146,7 @@
WebContents::FromRenderFrameHost(&render_frame_host()));
web_contents_sensor_provider->GetSensor(
- type, mojo::NullRemote(),
+ type, mojo::NullReceiver(), /*initially_suspended=*/false,
base::BindOnce(
&FrameSensorProviderProxy::OnHardwareCheckForBlockedSensor,
weak_factory_.GetWeakPtr(), type));
@@ -158,33 +160,37 @@
scoped_observation_.Observe(web_contents_sensor_provider);
}
- if (base::FeatureList::IsEnabled(
- features::kSeverSensorConnectionsOnPermissionRevocation) &&
- !permission_subscription_id_) {
- permission_subscription_id_ =
- permission_controller->SubscribeToPermissionResultChange(
- content::PermissionDescriptorUtil::
- CreatePermissionDescriptorForPermissionType(
- blink::PermissionType::SENSORS),
- nullptr, &render_frame_host(),
- render_frame_host().GetLastCommittedOrigin().GetURL(),
- /*should_include_device_status=*/false,
- base::BindRepeating(&FrameSensorProviderProxy::OnPermissionChanged,
- weak_factory_.GetWeakPtr()));
- }
+ mojo::PendingReceiver<device::mojom::SensorClientController>
+ controller_receiver;
+ mojo::PendingRemote<device::mojom::SensorClientController> controller;
+ bool initially_suspended = false;
- // Create watcher but defer binding until success
- mojo::PendingRemote<device::mojom::SensorConnectionWatcher> watcher;
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver;
if (ShouldTrackSensorConnection()) {
- receiver = watcher.InitWithNewPipeAndPassReceiver();
+ if (!permission_subscription_id_) {
+ permission_subscription_id_ =
+ permission_controller->SubscribeToPermissionResultChange(
+ content::PermissionDescriptorUtil::
+ CreatePermissionDescriptorForPermissionType(
+ blink::PermissionType::SENSORS),
+ nullptr, &render_frame_host(),
+ render_frame_host().GetLastCommittedOrigin().GetURL(),
+ /*should_include_device_status=*/false,
+ base::BindRepeating(
+ &FrameSensorProviderProxy::OnPermissionChanged,
+ weak_factory_.GetWeakPtr()));
+ }
+
+ controller_receiver = controller.InitWithNewPipeAndPassReceiver();
+ if (base::FeatureList::IsEnabled(features::kSensorPrivacyMitigations)) {
+ initially_suspended = ShouldSuspendSensors();
+ }
}
web_contents_sensor_provider->GetSensor(
- type, std::move(watcher),
+ type, std::move(controller_receiver), initially_suspended,
base::BindOnce(&FrameSensorProviderProxy::OnHardwareCheckCompleted,
weak_factory_.GetWeakPtr(), type, permission_status,
- has_valid_gesture, std::move(receiver),
+ has_valid_gesture, std::move(controller),
std::move(callback)));
}
@@ -192,7 +198,7 @@
device::mojom::SensorType type,
blink::mojom::PermissionStatus permission_status,
bool user_gesture,
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver,
+ mojo::PendingRemote<device::mojom::SensorClientController> controller,
GetSensorCallback callback,
device::mojom::SensorCreationResult result,
device::mojom::SensorInitParamsPtr params) {
@@ -207,7 +213,7 @@
}
if (permission_status == blink::mojom::PermissionStatus::GRANTED) {
- FinalizeSensorConnection(std::move(receiver));
+ FinalizeSensorConnection(std::move(controller));
std::move(callback).Run(result, std::move(params));
return;
}
@@ -227,7 +233,7 @@
user_gesture),
base::BindOnce(&FrameSensorProviderProxy::OnPermissionRequestCompleted,
weak_factory_.GetWeakPtr(), std::move(params),
- std::move(callback), std::move(receiver)));
+ std::move(callback), std::move(controller)));
}
void FrameSensorProviderProxy::OnHardwareCheckForBlockedSensor(
@@ -256,7 +262,7 @@
void FrameSensorProviderProxy::OnPermissionRequestCompleted(
device::mojom::SensorInitParamsPtr params,
GetSensorCallback callback,
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver,
+ mojo::PendingRemote<device::mojom::SensorClientController> controller,
PermissionResult permission_result) {
if (permission_result.status != blink::mojom::PermissionStatus::GRANTED) {
std::move(callback).Run(
@@ -264,7 +270,7 @@
return;
}
- FinalizeSensorConnection(std::move(receiver));
+ FinalizeSensorConnection(std::move(controller));
std::move(callback).Run(device::mojom::SensorCreationResult::SUCCESS,
std::move(params));
@@ -277,15 +283,49 @@
GetContentClient()->browser()->GetSensorDelegate();
if (delegate) {
// Notify the delegate for each active connection that is being cleared.
- for (size_t i = 0; i < watcher_receivers_.size(); ++i) {
+ for (size_t i = 0; i < client_controllers_.size(); ++i) {
delegate->OnSensorStopped(&render_frame_host());
}
}
- watcher_receivers_.Clear();
+ client_controllers_.Clear();
}
}
-void FrameSensorProviderProxy::OnSensorDisconnect() {
+void FrameSensorProviderProxy::OnVisibilityChanged(
+ content::Visibility visibility) {
+ UpdateSensorSessionControllers();
+}
+
+void FrameSensorProviderProxy::RenderFrameHostStateChanged(
+ RenderFrameHost* render_frame_host,
+ RenderFrameHost::LifecycleState old_state,
+ RenderFrameHost::LifecycleState new_state) {
+ if (render_frame_host == &this->render_frame_host()) {
+ UpdateSensorSessionControllers();
+ }
+}
+
+bool FrameSensorProviderProxy::ShouldSuspendSensors() const {
+ return !web_contents() || !render_frame_host().IsActive() ||
+ web_contents()->GetVisibility() == content::Visibility::HIDDEN;
+}
+
+void FrameSensorProviderProxy::UpdateSensorSessionControllers() {
+ bool suspended = ShouldSuspendSensors();
+ if (is_suspended_ && *is_suspended_ == suspended) {
+ return;
+ }
+ is_suspended_ = suspended;
+ for (auto& controller : client_controllers_) {
+ if (suspended) {
+ controller->Suspend();
+ } else {
+ controller->Resume();
+ }
+ }
+}
+
+void FrameSensorProviderProxy::OnSensorDisconnect(mojo::RemoteSetElementId id) {
SensorDelegate* delegate = GetContentClient()->browser()->GetSensorDelegate();
if (delegate) {
delegate->OnSensorStopped(&render_frame_host());
@@ -293,29 +333,28 @@
}
void FrameSensorProviderProxy::FinalizeSensorConnection(
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver) {
+ mojo::PendingRemote<device::mojom::SensorClientController> controller) {
SensorDelegate* delegate = GetContentClient()->browser()->GetSensorDelegate();
- bool bound_watcher = false;
- if (ShouldTrackSensorConnection() && receiver.is_valid()) {
- watcher_receivers_.Add(this, std::move(receiver));
- bound_watcher = true;
+ bool bound_controller = false;
+ if (ShouldTrackSensorConnection() && controller.is_valid()) {
+ client_controllers_.Add(std::move(controller));
+ bound_controller = true;
}
if (delegate) {
delegate->SetRequestedSensorIsAvailable(&render_frame_host(), true);
- if (bound_watcher) {
+ if (bound_controller) {
delegate->OnSensorStarted(&render_frame_host());
}
}
}
bool FrameSensorProviderProxy::ShouldTrackSensorConnection() {
- if (base::FeatureList::IsEnabled(
- features::kSeverSensorConnectionsOnPermissionRevocation)) {
- return true;
- }
return base::FeatureList::IsEnabled(
- content_settings::features::kLeftHandSideSensorActivityIndicators);
+ features::kSensorsAllowAskBlockPermissionModel) ||
+ base::FeatureList::IsEnabled(features::kSensorPrivacyMitigations) ||
+ base::FeatureList::IsEnabled(
+ content_settings::features::kLeftHandSideSensorActivityIndicators);
}
DOCUMENT_USER_DATA_KEY_IMPL(FrameSensorProviderProxy);
diff --git a/content/browser/generic_sensor/frame_sensor_provider_proxy.h b/content/browser/generic_sensor/frame_sensor_provider_proxy.h
index 26b409f5..a365e0d 100644
--- a/content/browser/generic_sensor/frame_sensor_provider_proxy.h
+++ b/content/browser/generic_sensor/frame_sensor_provider_proxy.h
@@ -5,6 +5,8 @@
#ifndef CONTENT_BROWSER_GENERIC_SENSOR_FRAME_SENSOR_PROVIDER_PROXY_H_
#define CONTENT_BROWSER_GENERIC_SENSOR_FRAME_SENSOR_PROVIDER_PROXY_H_
+#include <optional>
+
#include "base/memory/weak_ptr.h"
#include "base/scoped_observation.h"
#include "content/browser/generic_sensor/web_contents_sensor_provider_proxy.h"
@@ -12,10 +14,12 @@
#include "content/public/browser/document_user_data.h"
#include "content/public/browser/permission_controller.h"
#include "content/public/browser/permission_result.h"
+#include "content/public/browser/web_contents_observer.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/receiver_set.h"
+#include "mojo/public/cpp/bindings/remote_set.h"
#include "services/device/public/mojom/sensor.mojom-shared.h"
-#include "services/device/public/mojom/sensor.mojom.h"
+#include "services/device/public/mojom/sensor_provider.mojom.h"
#include "third_party/blink/public/mojom/permissions/permission_status.mojom-shared.h"
#include "third_party/blink/public/mojom/sensor/web_sensor_provider.mojom.h"
@@ -27,7 +31,7 @@
: public blink::mojom::WebSensorProvider,
public WebContentsSensorProviderProxy::Observer,
public DocumentUserData<FrameSensorProviderProxy>,
- public device::mojom::SensorConnectionWatcher {
+ public WebContentsObserver {
public:
FrameSensorProviderProxy(const FrameSensorProviderProxy&) = delete;
FrameSensorProviderProxy& operator=(const FrameSensorProviderProxy&) = delete;
@@ -39,6 +43,13 @@
// WebContentsSensorProviderProxy::Observer overrides.
void OnMojoConnectionError() override;
+ // WebContentsObserver overrides.
+ void OnVisibilityChanged(content::Visibility visibility) override;
+ void RenderFrameHostStateChanged(
+ RenderFrameHost* render_frame_host,
+ RenderFrameHost::LifecycleState old_state,
+ RenderFrameHost::LifecycleState new_state) override;
+
private:
explicit FrameSensorProviderProxy(RenderFrameHost* render_frame_host);
@@ -51,7 +62,7 @@
device::mojom::SensorType type,
blink::mojom::PermissionStatus permission_status,
bool user_gesture,
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver,
+ mojo::PendingRemote<device::mojom::SensorClientController> controller,
GetSensorCallback callback,
device::mojom::SensorCreationResult result,
device::mojom::SensorInitParamsPtr params);
@@ -64,17 +75,17 @@
void OnPermissionRequestCompleted(
device::mojom::SensorInitParamsPtr params,
GetSensorCallback callback,
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver,
+ mojo::PendingRemote<device::mojom::SensorClientController> controller,
PermissionResult permission_result);
void OnPermissionChanged(PermissionResult permission_result);
- void OnSensorDisconnect();
+ void OnSensorDisconnect(mojo::RemoteSetElementId id);
bool ShouldTrackSensorConnection();
void FinalizeSensorConnection(
- mojo::PendingReceiver<device::mojom::SensorConnectionWatcher> receiver);
+ mojo::PendingRemote<device::mojom::SensorClientController> controller);
mojo::ReceiverSet<blink::mojom::WebSensorProvider> receiver_set_;
@@ -82,8 +93,12 @@
WebContentsSensorProviderProxy::Observer>
scoped_observation_{this};
- mojo::ReceiverSet<device::mojom::SensorConnectionWatcher> watcher_receivers_;
+ bool ShouldSuspendSensors() const;
+ void UpdateSensorSessionControllers();
+
+ mojo::RemoteSet<device::mojom::SensorClientController> client_controllers_;
PermissionController::SubscriptionId permission_subscription_id_;
+ std::optional<bool> is_suspended_;
base::WeakPtrFactory<FrameSensorProviderProxy> weak_factory_{this};
diff --git a/content/browser/generic_sensor/web_contents_sensor_provider_proxy.cc b/content/browser/generic_sensor/web_contents_sensor_provider_proxy.cc
index f2dea67..3af06e5 100644
--- a/content/browser/generic_sensor/web_contents_sensor_provider_proxy.cc
+++ b/content/browser/generic_sensor/web_contents_sensor_provider_proxy.cc
@@ -114,10 +114,12 @@
void WebContentsSensorProviderProxy::GetSensor(
SensorType type,
- mojo::PendingRemote<device::mojom::SensorConnectionWatcher> watcher,
+ mojo::PendingReceiver<device::mojom::SensorClientController> controller,
+ bool initially_suspended,
device::mojom::SensorProvider::GetSensorCallback callback) {
EnsureDeviceServiceConnection();
- sensor_provider_->GetSensor(type, std::move(watcher), std::move(callback));
+ sensor_provider_->GetSensor(type, std::move(controller), initially_suspended,
+ std::move(callback));
}
void WebContentsSensorProviderProxy::CreateVirtualSensor(
diff --git a/content/browser/generic_sensor/web_contents_sensor_provider_proxy.h b/content/browser/generic_sensor/web_contents_sensor_provider_proxy.h
index e1090aeb..a4aa6db5 100644
--- a/content/browser/generic_sensor/web_contents_sensor_provider_proxy.h
+++ b/content/browser/generic_sensor/web_contents_sensor_provider_proxy.h
@@ -46,7 +46,8 @@
void GetSensor(
device::mojom::SensorType type,
- mojo::PendingRemote<device::mojom::SensorConnectionWatcher> watcher,
+ mojo::PendingReceiver<device::mojom::SensorClientController> controller,
+ bool initially_suspended,
device::mojom::SensorProvider::GetSensorCallback callback);
// Attempts to create and return a ScopedVirtualSensorForDevTools instance of
diff --git a/device/vr/orientation/orientation_device.cc b/device/vr/orientation/orientation_device.cc
index f732df6..ae9e442a 100644
--- a/device/vr/orientation/orientation_device.cc
+++ b/device/vr/orientation/orientation_device.cc
@@ -16,6 +16,7 @@
#include "base/no_destructor.h"
#include "base/time/time.h"
#include "device/vr/orientation/orientation_session.h"
+#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "services/device/public/cpp/generic_sensor/sensor_reading.h"
#include "services/device/public/cpp/generic_sensor/sensor_reading_shared_buffer.h"
@@ -62,7 +63,8 @@
: VRDeviceBase(mojom::XRDeviceId::ORIENTATION_DEVICE_ID),
ready_callback_(std::move(ready_callback)) {
DVLOG(2) << __func__;
- sensor_provider->GetSensor(kOrientationSensorType, mojo::NullRemote(),
+ sensor_provider->GetSensor(kOrientationSensorType, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&VROrientationDevice::SensorReady,
base::Unretained(this)));
diff --git a/services/device/generic_sensor/sensor_impl.cc b/services/device/generic_sensor/sensor_impl.cc
index af219b09..b259618 100644
--- a/services/device/generic_sensor/sensor_impl.cc
+++ b/services/device/generic_sensor/sensor_impl.cc
@@ -11,19 +11,48 @@
namespace device {
+// Receives suspend/resume control messages from the browser process via Mojo
+// and forwards them to the associated SensorImpl. This allows the browser
+// to supervise the sensor session (e.g. suspending it when the page is
+// backgrounded or in BFCache).
+class SensorImpl::SensorClientControllerImpl
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/generic_sensor/frame_sensor_provider_proxy_unittest.cc b/content/browser/generic_sensor/frame_sensor_provider_proxy_unittest.cc
index dfb3c42..fc8d0a5 100644
--- a/content/browser/generic_sensor/frame_sensor_provider_proxy_unittest.cc
+++ b/content/browser/generic_sensor/frame_sensor_provider_proxy_unittest.cc
@@ -259,5 +259,61 @@
EXPECT_EQ(result, device::mojom::SensorCreationResult::ERROR_NOT_ALLOWED);
}
+TEST_F(FrameSensorProviderProxyTest,
+ GetSensor_VisibilityChanged_SuspendsAndResumes) {
+ EXPECT_CALL(*permission_manager(),
+ GetPermissionResultForCurrentDocument(_, _, _))
+ .WillOnce(Return(PermissionResult(blink::mojom::PermissionStatus::GRANTED,
+ PermissionStatusSource::UNSPECIFIED)));
+
+ auto provider = GetWebSensorProvider();
+ static_cast<TestRenderFrameHost*>(main_test_rfh())->SimulateUserActivation();
+
+ mojo::Remote<device::mojom::Sensor> sensor_remote;
+ base::test::TestFuture<device::mojom::SensorCreationResult,
+ device::mojom::SensorInitParamsPtr>
+ future;
+ provider->GetSensor(device::mojom::SensorType::ACCELEROMETER,
+ /*user_gesture=*/true, future.GetCallback());
+ auto [result, params] = future.Take();
+ EXPECT_EQ(result, device::mojom::SensorCreationResult::SUCCESS);
+ sensor_remote.Bind(std::move(params->sensor));
+
+ device::FakeSensor* fake_sensor = fake_sensor_provider()->accelerometer();
+ ASSERT_TRUE(fake_sensor);
+
+ web_contents()->WasHidden();
+ EXPECT_TRUE(fake_sensor->WaitForBrowserSuspend(true));
+
+ web_contents()->WasShown();
+ EXPECT_TRUE(fake_sensor->WaitForBrowserSuspend(false));
+}
+
+TEST_F(FrameSensorProviderProxyTest,
+ GetSensor_InitiallyHidden_StartsSuspended) {
+ EXPECT_CALL(*permission_manager(),
+ GetPermissionResultForCurrentDocument(_, _, _))
+ .WillOnce(Return(PermissionResult(blink::mojom::PermissionStatus::GRANTED,
+ PermissionStatusSource::UNSPECIFIED)));
+
+ web_contents()->WasHidden();
+ auto provider = GetWebSensorProvider();
+ static_cast<TestRenderFrameHost*>(main_test_rfh())->SimulateUserActivation();
+
+ mojo::Remote<device::mojom::Sensor> sensor_remote;
+ base::test::TestFuture<device::mojom::SensorCreationResult,
+ device::mojom::SensorInitParamsPtr>
+ future;
+ provider->GetSensor(device::mojom::SensorType::ACCELEROMETER,
+ /*user_gesture=*/true, future.GetCallback());
+ auto [result, params] = future.Take();
+ EXPECT_EQ(result, device::mojom::SensorCreationResult::SUCCESS);
+ sensor_remote.Bind(std::move(params->sensor));
+
+ device::FakeSensor* fake_sensor = fake_sensor_provider()->accelerometer();
+ ASSERT_TRUE(fake_sensor);
+ EXPECT_TRUE(fake_sensor->is_browser_suspended());
+}
+
} // namespace
} // namespace content
diff --git a/content/browser/generic_sensor/web_contents_sensor_provider_proxy_unittest.cc b/content/browser/generic_sensor/web_contents_sensor_provider_proxy_unittest.cc
index 3ce1944..5daedf4b 100644
--- a/content/browser/generic_sensor/web_contents_sensor_provider_proxy_unittest.cc
+++ b/content/browser/generic_sensor/web_contents_sensor_provider_proxy_unittest.cc
@@ -137,11 +137,12 @@
void GetSensor(
device::mojom::SensorType type,
- mojo::PendingRemote<device::mojom::SensorConnectionWatcher> watcher,
+ mojo::PendingReceiver<device::mojom::SensorClientController> controller,
+ bool initially_suspended,
GetSensorCallback callback) override {
std::move(interception_callback_).Run();
- device::FakeSensorProvider::GetSensor(type, std::move(watcher),
- std::move(callback));
+ device::FakeSensorProvider::GetSensor(
+ type, std::move(controller), initially_suspended, std::move(callback));
}
private:
@@ -202,7 +203,7 @@
ASSERT_TRUE(fake_sensor);
base::RunLoop run_loop;
- fake_sensor->SetWatcherDisconnectCallback(run_loop.QuitClosure());
+ fake_sensor->SetControllerDisconnectCallback(run_loop.QuitClosure());
// Trigger permission revocation.
TestPermissionManager* permission_manager =
diff --git a/device/vr/test/fake_sensor_provider.cc b/device/vr/test/fake_sensor_provider.cc
index 09c2c7de..ac573989 100644
--- a/device/vr/test/fake_sensor_provider.cc
+++ b/device/vr/test/fake_sensor_provider.cc
@@ -29,7 +29,8 @@
void FakeXRSensorProvider::GetSensor(
mojom::SensorType type,
- mojo::PendingRemote<mojom::SensorConnectionWatcher> watcher,
+ mojo::PendingReceiver<mojom::SensorClientController> controller,
+ bool initially_suspended,
GetSensorCallback callback) {
callback_ = std::move(callback);
}
diff --git a/device/vr/test/fake_sensor_provider.h b/device/vr/test/fake_sensor_provider.h
index d82eff2..8736e35 100644
--- a/device/vr/test/fake_sensor_provider.h
+++ b/device/vr/test/fake_sensor_provider.h
@@ -8,7 +8,6 @@
#include "device/vr/vr_export.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/receiver.h"
-#include "services/device/public/mojom/sensor.mojom.h"
#include "services/device/public/mojom/sensor_provider.mojom.h"
namespace device {
@@ -24,9 +23,11 @@
void CallCallback(mojom::SensorInitParamsPtr param);
// device::mojom::SensorProvider overrides.
- void GetSensor(mojom::SensorType type,
- mojo::PendingRemote<mojom::SensorConnectionWatcher> watcher,
- GetSensorCallback callback) override;
+ void GetSensor(
+ mojom::SensorType type,
+ mojo::PendingReceiver<mojom::SensorClientController> controller,
+ bool initially_suspended,
+ GetSensorCallback callback) override;
void CreateVirtualSensor(
mojom::SensorType type,
mojom::VirtualSensorMetadataPtr metadata,
diff --git a/services/device/generic_sensor/generic_sensor_service_unittest.cc b/services/device/generic_sensor/generic_sensor_service_unittest.cc
index cc5d0cf2..83759ec 100644
--- a/services/device/generic_sensor/generic_sensor_service_unittest.cc
+++ b/services/device/generic_sensor/generic_sensor_service_unittest.cc
@@ -14,6 +14,7 @@
#include "base/run_loop.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
+#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "services/device/device_service.h"
@@ -245,7 +246,8 @@
auto client = std::make_unique<TestSensorClient>(SensorType::ACCELEROMETER);
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -260,7 +262,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -285,7 +288,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::AMBIENT_LIGHT, mojo::NullRemote(),
+ SensorType::AMBIENT_LIGHT, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -307,7 +311,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::LINEAR_ACCELERATION, mojo::NullRemote(),
+ SensorType::LINEAR_ACCELERATION, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -329,11 +334,13 @@
base::RunLoop run_loop;
auto barrier_closure = base::BarrierClosure(2, run_loop.QuitClosure());
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client_1.get()), barrier_closure));
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client_2.get()), barrier_closure));
run_loop.Run();
@@ -360,11 +367,13 @@
base::RunLoop run_loop;
auto barrier_closure = base::BarrierClosure(2, run_loop.QuitClosure());
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client_1.get()), barrier_closure));
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client_2.get()), barrier_closure));
run_loop.Run();
@@ -389,7 +398,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -430,7 +440,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::AMBIENT_LIGHT, mojo::NullRemote(),
+ SensorType::AMBIENT_LIGHT, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -462,7 +473,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::AMBIENT_LIGHT, mojo::NullRemote(),
+ SensorType::AMBIENT_LIGHT, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -495,7 +507,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -541,11 +554,13 @@
base::RunLoop run_loop;
auto barrier_closure = base::BarrierClosure(2, run_loop.QuitClosure());
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client_1.get()), barrier_closure));
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client_2.get()), barrier_closure));
run_loop.Run();
@@ -571,7 +586,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(client.get()), run_loop.QuitClosure()));
run_loop.Run();
@@ -608,7 +624,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
- SensorType::ACCELEROMETER, mojo::NullRemote(),
+ SensorType::ACCELEROMETER, mojo::NullReceiver(),
+ /*initially_suspended=*/false,
base::BindOnce(&TestSensorClient::OnSensorCreated,
base::Unretained(new_client.get()),
run_loop.QuitClosure()));
@@ -664,7 +681,8 @@
{
base::RunLoop run_loop;
sensor_provider_->GetSensor(
... (truncated)
Original Bug Report
Missing browser-side enforcement for Generic Sensor privacy mitigations
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: The browser process brokers a direct Mojo connection for Generic Sensors between a renderer and the Device Service without retaining control of the pipe. This potentially allows a compromised renderer to bypass visibility/focus gating, BFCache suspension, and permission revocation. An attacker could thereby continuously monitor sensitive motion data in the background or across cross-origin navigations.
Affected files:
content/browser/generic_sensor/frame_sensor_provider_proxy.cccontent/browser/generic_sensor/web_contents_sensor_provider_proxy.ccservices/device/generic_sensor/sensor_provider_impl.ccservices/device/generic_sensor/sensor_impl.ccthird_party/blink/renderer/modules/sensor/sensor_proxy.cc
Estimated timestamp from git blame: 2023-10-18
Vulnerability Details
The Generic Sensor API implementation currently suffers from a structural flaw where the browser process fails to retain control over sensor pipes after they are brokered to a renderer.
When a renderer requests a sensor, content::FrameSensorProviderProxy::GetSensor() performs a one-time permission and policy check. Upon success, it simply forwards the renderer’s Mojo callback to the Device Service (services/device/generic_sensor/sensor_provider_impl.cc). The Device Service creates a device::SensorImpl instance, bounds it to a mojo::UniqueReceiverSet<mojom::Sensor>, and returns a direct mojom::Sensor pipe and a shared-memory region handle directly to the renderer.
Crucially, neither FrameSensorProviderProxy nor WebContentsSensorProviderProxy retains a reference to this mojom::Sensor pipe. This architectural hand-off leads to several severe privacy mitigation bypasses:
- Visibility and Focus Gating Bypass: The Generic Sensor specification requires that sensors only provide data to visible, focused frames. This is currently enforced entirely client-side by the renderer (
blink::SensorProxy::ShouldSuspendUpdates()). The Device Service’sSensorImplblindly trustsResume()andSuspend()calls from the renderer. A compromised renderer can simply callResume()and ignore visibility state. - Permission Revocation Bypass: Because the browser proxy does not retain the brokered sensor pipes, it cannot close them if the user later revokes the site’s “Sensors” permission. The direct pipe to the Device Service remains active.
- Navigation and BFCache Bypass: When a document is destroyed or enters the BFCache,
FrameSensorProviderProxyis destroyed (as it is aDocumentUserData). However, destroying the proxy only severs the brokeringWebSensorProviderpipe. The actualSensorImplin the Device Service remains fully alive because its lifetime is tied to the renderer holding themojom::Sensorremote.
Suggested Attacker Steps
(Note: These are potential steps based on code analysis; our tooling agent does not yet have the ability to run code to produce a working PoC.)
- Achieve code execution within a sandboxed renderer process (e.g., via a separate V8 or Blink vulnerability).
- Construct a Mojo request to
blink::mojom::WebSensorProvider::GetSensor()for a sensitive sensor likeACCELEROMETER. - Because the page is active and motion sensors often default to allowed, the browser process’s initial permission check passes.
- Receive the
device::mojom::SensorInitParamsresponse, which contains a directmojom::Sensorremote to the Device Service and a read-only handle to the sensor’s shared memory buffer. - Call the
Resume()method on themojom::Sensorremote to instruct the Device Service to start polling the hardware. - Intentionally ignore any backgrounding, visibility loss, or focus loss events. Do not call
Suspend()on the remote. - Continue to read high-resolution motion data from the shared memory buffer indefinitely, even after the user navigates away or revokes permissions, enabling persistent side-channel attacks (e.g., keystroke inference).
Suggested Fix
To resolve this, the browser process must act as the authoritative gatekeeper for the lifetime and state of active sensors:
- Active Proxying or Tracking:
FrameSensorProviderProxyshould either actively proxy thedevice::mojom::Sensorinterface (interceptingSuspend/Resumeand sensor data notifications) or track the returned pipes so they can be forcefully disconnected. - Browser-side State Enforcement: The browser process should independently monitor the
WebContentsvisibility, focus state, andPermissionControllerstatus. When a frame loses visibility/focus, or if permissions are revoked, the browser should forcefully suspend or disconnect the tracked sensor pipes, rather than relying on the untrusted renderer to do so voluntarily.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.