Medium watch CVSS 5.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Sensor
DescriptionMissing authorization in Sensor
ComponentSensor
Sub-componentSensors
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497017869
Fix commit7d7f3150edf7 (chromium/src) +431/-154
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-27 (per vendor advisory)
Fix landed2026-07-06 (public git)
Disclosed2026-08-25
Patch gap50 days — fix public in source before the advisory

Background

`FrameSensorProviderProxy`
the browser-side, per-document (DocumentUserData) broker that receives a renderer’s blink::mojom::WebSensorProvider::GetSensor request, checks the SENSORS permission, and forwards the request to the device service through WebContentsSensorProviderProxy.
`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 SensorImpl implements (SensorImpl::SensorClientControllerImpl); the browser now keeps the Remote end in a mojo::RemoteSet (client_controllers_) and can call Suspend() / 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, and WebContents::GetVisibility() returns HIDDEN for 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.

Key insight
The browser authorized a sensor once, at 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

  1. Obtain a sensor A page calls the Generic Sensor API (for example, the accelerometer). FrameSensorProviderProxy::GetSensor sees GRANTED (or gets it through the prompt flow) and returns SensorInitParams, as in the regression test.
  2. 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.
  3. 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_SuspendsAndResumes and GetSensor_InitiallyHidden_StartsSuspended assert that this is now suspended.
  4. 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

A web page that already held sensor access could keep receiving sensor data while its tab was hidden or its document was inactive, and possibly after a permission change when the severing feature was off. That bypasses the user-facing authorization boundary for a privacy-sensitive capability. No memory corruption is involved; the issue is a renderer-reachable authorization gap enforced in the browser and device-service processes. The vendor rates it medium, and the provided material does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
content/browser/generic_sensor/frame_sensor_provider_proxy.cc
modified
for
content/browser/generic_sensor/frame_sensor_provider_proxy.cc
modified
SensorImpl
services/device/generic_sensor/sensor_impl.cc
modified

Files Changed

  • content/browser/generic_sensor/frame_sensor_provider_proxy.cc
  • content/browser/generic_sensor/frame_sensor_provider_proxy.h
  • content/browser/generic_sensor/frame_sensor_provider_proxy_unittest.cc
  • content/browser/generic_sensor/web_contents_sensor_provider_proxy.cc
  • content/browser/generic_sensor/web_contents_sensor_provider_proxy.h
  • content/browser/generic_sensor/web_contents_sensor_provider_proxy_unittest.cc
  • device/vr/orientation/orientation_device.cc
  • device/vr/test/fake_sensor_provider.cc
  • device/vr/test/fake_sensor_provider.h
  • services/device/generic_sensor/generic_sensor_service_unittest.cc
  • services/device/generic_sensor/sensor_impl.cc
  • services/device/generic_sensor/sensor_impl.h
  • services/device/generic_sensor/sensor_provider_impl.cc
  • services/device/generic_sensor/sensor_provider_impl.h
  • services/device/public/cpp/device_features.cc
  • services/device/public/cpp/device_features.h
  • services/device/public/cpp/test/fake_sensor_and_provider.cc
  • services/device/public/cpp/test/fake_sensor_and_provider.h
  • services/device/public/mojom/sensor.mojom
  • services/device/public/mojom/sensor_provider.mojom
  • third_party/blink/renderer/modules/sensor/sensor_test_utils.cc

Audit Directions

  • Grant-once capability streams
    Look 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 revocation
    Look for permission-revocation or privacy enforcement that runs only behind a feature flag, as kSeverSensorConnectionsOnPermissionRevocation did; when the flag is off, the authorization check silently degrades.
  • Inverted control pipes
    Check whether the trusted process holds a controlling Remote to the service, or only a passive watcher; a watcher-only design cannot enforce a policy change on an already-live session.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/browser/generic_sensor/web_contents_sensor_provider_proxy.cc
  • services/device/generic_sensor/sensor_provider_impl.cc
  • services/device/generic_sensor/sensor_impl.cc
  • third_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:

  1. 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’s SensorImpl blindly trusts Resume() and Suspend() calls from the renderer. A compromised renderer can simply call Resume() and ignore visibility state.
  2. 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.
  3. Navigation and BFCache Bypass: When a document is destroyed or enters the BFCache, FrameSensorProviderProxy is destroyed (as it is a DocumentUserData). However, destroying the proxy only severs the brokering WebSensorProvider pipe. The actual SensorImpl in the Device Service remains fully alive because its lifetime is tied to the renderer holding the mojom::Sensor remote.

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.)

  1. Achieve code execution within a sandboxed renderer process (e.g., via a separate V8 or Blink vulnerability).
  2. Construct a Mojo request to blink::mojom::WebSensorProvider::GetSensor() for a sensitive sensor like ACCELEROMETER.
  3. Because the page is active and motion sensors often default to allowed, the browser process’s initial permission check passes.
  4. Receive the device::mojom::SensorInitParams response, which contains a direct mojom::Sensor remote to the Device Service and a read-only handle to the sensor’s shared memory buffer.
  5. Call the Resume() method on the mojom::Sensor remote to instruct the Device Service to start polling the hardware.
  6. Intentionally ignore any backgrounding, visibility loss, or focus loss events. Do not call Suspend() on the remote.
  7. 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:

  1. Active Proxying or Tracking: FrameSensorProviderProxy should either actively proxy the device::mojom::Sensor interface (intercepting Suspend/Resume and sensor data notifications) or track the returned pipes so they can be forcefully disconnected.
  2. Browser-side State Enforcement: The browser process should independently monitor the WebContents visibility, focus state, and PermissionController status. 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.

View on issue tracker