Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Geolocation
DescriptionInappropriate implementation in Geolocation
ComponentGeolocation
Bug ClassLogic Error
Tracker514489361
Fix commit94e14bcc2f78 (chromium/src) +173/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
GeolocationServiceImpl
content/browser/geolocation/geolocation_service_impl.cc
modified
erase_if
content/browser/geolocation/geolocation_service_impl.cc
modified
GeolocationProxy
content/browser/geolocation/geolocation_service_impl.h
modified

Files Changed

  • content/browser/geolocation/geolocation_service_impl.cc
  • content/browser/geolocation/geolocation_service_impl.h
From 94e14bcc2f78d07ca7e0d0dfa46a7fe6fe9226c0 Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Thu, 21 May 2026 08:54:47 -0700
Subject: [PATCH] geolocation: Manage activity count via GeolocationProxy

Introduce GeolocationProxy to manage the lifetime of the Geolocation
Mojo connection between the renderer and GeolocationImpl.

Previously, GeolocationServiceImpl managed the activity count (affecting
UI indicators) based on the broker pipe lifetime. However, the renderer
could disconnect the broker pipe while keeping the data pipe open,
potentially leading to silent location access.

To fix this, GeolocationProxy is introduced as an intermediary that
forwards calls and monitors connection status. GeolocationServiceImpl
now tracks active proxies and manages the activity count based on their
lifetime, ensuring the location indicator remains active as long as the
data pipe is open.

Bug: 514489361
Change-Id: If37960ecc038f2cf9a57a49355d7d809be347155
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7862524
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Reviewed-by: Avi Drissman <avi@chromium.org>
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1634321}
---

diff --git a/content/browser/geolocation/geolocation_service_impl.cc b/content/browser/geolocation/geolocation_service_impl.cc
index 312c511..d7bca56 100644
--- a/content/browser/geolocation/geolocation_service_impl.cc
+++ b/content/browser/geolocation/geolocation_service_impl.cc
@@ -12,6 +12,7 @@
 #include "components/content_settings/core/common/features.h"
 #include "content/browser/permissions/permission_controller_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
+#include "content/common/features.h"
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/permission_controller.h"
 #include "content/public/browser/permission_descriptor_util.h"
@@ -29,6 +30,50 @@
 
 namespace content {
 
+// `GeolocationProxy` acts as a Mojo intermediary between the renderer client
+// and the backing `GeolocationImpl`. It forwards all calls and monitors
+// the connection lifetime of both ends.
+//
+// `GeolocationServiceImpl` uses this to track active sessions and manage
+// the browser-side activity count (UI location indicator). Disconnections on
+// either end trigger `OnProxyDisconnected` to update state.
+class GeolocationServiceImpl::GeolocationProxy
+    : public device::mojom::Geolocation {
+ public:
+  GeolocationProxy(
+      GeolocationServiceImpl* service_impl,
+      mojo::PendingRemote<device::mojom::Geolocation> geolocation_impl_remote,
+      mojo::PendingReceiver<device::mojom::Geolocation> renderer_receiver)
+      : service_impl_(service_impl),
+        geolocation_impl_remote_(std::move(geolocation_impl_remote)),
+        renderer_receiver_(this, std::move(renderer_receiver)) {
+    renderer_receiver_.set_disconnect_handler(base::BindOnce(
+        &GeolocationProxy::OnDisconnect, base::Unretained(this)));
+    geolocation_impl_remote_.set_disconnect_handler(base::BindOnce(
+        &GeolocationProxy::OnDisconnect, base::Unretained(this)));
+  }
+
+  // device::mojom::Geolocation:
+  void SetHighAccuracyHint(bool high_accuracy) override {
+    geolocation_impl_remote_->SetHighAccuracyHint(high_accuracy);
+  }
+  void QueryCachedPosition(QueryCachedPositionCallback callback) override {
+    geolocation_impl_remote_->QueryCachedPosition(std::move(callback));
+  }
+  void QueryNextPosition(QueryNextPositionCallback callback) override {
+    geolocation_impl_remote_->QueryNextPosition(std::move(callback));
+  }
+
+ private:
+  void OnDisconnect() { service_impl_->OnProxyDisconnected(this); }
+
+  // `GeolocationServiceImpl` owns `this` (via `active_proxies_`), so
+  // `service_impl_` is guaranteed to outlive `this`.
+  const raw_ptr<GeolocationServiceImpl> service_impl_;
+  mojo::Remote<device::mojom::Geolocation> geolocation_impl_remote_;
+  mojo::Receiver<device::mojom::Geolocation> renderer_receiver_;
+};
+
 namespace {
 
 using GeolocationPermissionLevel = device::mojom::GeolocationPermissionLevel;
@@ -174,9 +219,6 @@
     return;
   }
 
-  std::move(callback).Run(blink::mojom::PermissionStatus::GRANTED);
-  IncrementActivityCount();
-
   requesting_origin_ =
       render_frame_host_->GetMainFrame()->GetLastCommittedOrigin();
   auto requesting_url =
@@ -184,10 +226,33 @@
 
   bool has_precise_permission =
       permission_level == GeolocationPermissionLevel::kPrecise;
-  geolocation_context->BindGeolocation(
-      std::move(receiver), requesting_origin_,
-      device::mojom::GeolocationClientId::kGeolocationServiceImpl,
-      has_precise_permission);
+
+  if (base::FeatureList::IsEnabled(features::kGeolocationProxy)) {
+    mojo::PendingRemote<device::mojom::Geolocation> geolocation_impl_remote;
+    geolocation_context->BindGeolocation(
+        geolocation_impl_remote.InitWithNewPipeAndPassReceiver(),
+        requesting_origin_,
+        device::mojom::GeolocationClientId::kGeolocationServiceImpl,
+        has_precise_permission);
+
+    if (active_proxies_.empty()) {
+      IncrementActivityCount();
+    }
+    active_proxies_.push_back(std::make_unique<GeolocationProxy>(
+        this, std::move(geolocation_impl_remote), std::move(receiver)));
+
+    std::move(callback).Run(blink::mojom::PermissionStatus::GRANTED);
+  } else {
+    std::move(callback).Run(blink::mojom::PermissionStatus::GRANTED);
+    // NOTE: Legacy behavior may leak the active frame count if multiple
+    // connections are created. Kept as-is for compatibility.
+    IncrementActivityCount();
+
+    geolocation_context->BindGeolocation(
+        std::move(receiver), requesting_origin_,
+        device::mojom::GeolocationClientId::kGeolocationServiceImpl,
+        has_precise_permission);
+  }
   subscription_id_ =
       PermissionControllerImpl::FromBrowserContext(
           render_frame_host_->GetBrowserContext())
@@ -218,14 +283,30 @@
     PermissionControllerImpl::FromBrowserContext(
         render_frame_host_->GetBrowserContext())
         ->UnsubscribeFromPermissionResultChange(subscription_id_);
-    DecrementActivityCount();
+    // When kGeolocationProxy is enabled, DecrementActivityCount is managed
+    // by the GeolocationProxy lifecycle.
+    if (!base::FeatureList::IsEnabled(features::kGeolocationProxy)) {
+      DecrementActivityCount();
+    }
   }
   geolocation_context->OnPermissionUpdated(requesting_origin_,
                                            permission_level);
 }
 
 void GeolocationServiceImpl::OnDisconnected() {
-  if (receiver_set_.empty()) {
+  // When kGeolocationProxy is enabled, we do not need to perform any cleanup
+  // here because active connection lifetimes are managed by the proxies.
+  if (!base::FeatureList::IsEnabled(features::kGeolocationProxy) &&
+      receiver_set_.empty()) {
+    DecrementActivityCount();
+  }
+}
+
+void GeolocationServiceImpl::OnProxyDisconnected(GeolocationProxy* proxy) {
+  std::erase_if(active_proxies_, [proxy](const auto& active_proxy) {
+    return active_proxy.get() == proxy;
+  });
+  if (active_proxies_.empty()) {
     DecrementActivityCount();
   }
 }
diff --git a/content/browser/geolocation/geolocation_service_impl.h b/content/browser/geolocation/geolocation_service_impl.h
index 227bbd9..a452fb2 100644
--- a/content/browser/geolocation/geolocation_service_impl.h
+++ b/content/browser/geolocation/geolocation_service_impl.h
@@ -77,6 +77,9 @@
   void OnDisconnected();
 
  private:
+  // Private helper to manage connection lifetimes.
+  class GeolocationProxy;
+
   // Creates the Geolocation Service.
   void CreateGeolocationWithPermissionResult(
       mojo::PendingReceiver<device::mojom::Geolocation> receiver,
@@ -88,6 +91,8 @@
 
   device::mojom::GeolocationContext* GetGeolocationContext();
 
+  void OnProxyDisconnected(GeolocationProxy* proxy);
+
   // Used to subscribe to permission status changes.
   PermissionController::SubscriptionId subscription_id_;
 
@@ -111,6 +116,10 @@
   // stopped because the permission status changed.
   bool is_sending_updates_ = false;
 
+  // Active proxies managing connections between the renderer and the backing
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/geolocation/geolocation_service_impl_unittest.cc b/content/browser/geolocation/geolocation_service_impl_unittest.cc
index 45c19b52..7a7b44d 100644
--- a/content/browser/geolocation/geolocation_service_impl_unittest.cc
+++ b/content/browser/geolocation/geolocation_service_impl_unittest.cc
@@ -17,6 +17,8 @@
 #include "components/content_settings/core/common/content_settings_utils.h"
 #include "components/content_settings/core/common/features.h"
 #include "content/browser/permissions/permission_controller_impl.h"
+#include "content/browser/web_contents/web_contents_impl.h"
+#include "content/common/features.h"
 #include "content/public/browser/device_service.h"
 #include "content/public/browser/permission_controller.h"
 #include "content/public/browser/permission_request_description.h"
@@ -461,5 +463,65 @@
   EXPECT_EQ(PermissionStatus::GRANTED, create_geolocation_future.Get());
 }
 
+TEST_P(GeolocationServiceTest, BrokerDisconnectDoesNotStopActivityCount) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kGeolocationProxy);
+
+  CreateEmbeddedFrameAndGeolocationService(
+      /*allow_via_permissions_policy=*/true);
+
+  permission_manager()->SetRequestCallback(
+      base::BindRepeating([](PermissionCallback permission_callback) {
+        base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+            FROM_HERE,
+            base::BindOnce(
+                std::move(permission_callback),
+                std::vector{content::PermissionResult(
+                    PermissionStatus::GRANTED,
+                    PermissionStatusSource::UNSPECIFIED,
+                    base::FeatureList::IsEnabled(
+                        content_settings::features::
+                            kApproximateGeolocationPermission)
+                        ? std::make_optional(GeolocationSetting{
+                              .approximate = PermissionOption::kAllowed,
+                              .precise = PermissionOption::kAllowed})
+                        : std::nullopt)}));
+      }));
+
+  mojo::Remote<Geolocation> geolocation;
+  TestFuture<blink::mojom::PermissionStatus> create_geolocation_future;
+  service_remote()->CreateGeolocation(
+      geolocation.BindNewPipeAndPassReceiver(), true,
+      blink::mojom::GeolocationAccuracy::kPrecise,
+      create_geolocation_future.GetCallback());
+
+  EXPECT_EQ(blink::mojom::PermissionStatus::GRANTED,
+            create_geolocation_future.Get());
+
+  WebContentsImpl* web_contents_impl =
+      static_cast<WebContentsImpl*>(web_contents());
+  EXPECT_TRUE(web_contents_impl->IsCapabilityActive(
+      WebContentsCapabilityType::kGeolocation));
+
+  // Disconnect the broker pipe.
+  service_remote().reset();
+  base::RunLoop().RunUntilIdle();
+
+  // Security/Privacy Check: Ensure the geolocation capability remains active
+  // as long as the data pipe is open, even if the broker pipe is disconnected.
+  // This guarantees the location indicator in the UI remains visible while
+  // the renderer can still receive updates.
+  EXPECT_TRUE(web_contents_impl->IsCapabilityActive(
+      WebContentsCapabilityType::kGeolocation));
+
+  // Close the data pipe.
+  geolocation.reset();
+  base::RunLoop().RunUntilIdle();
+
+  // Active state should now be false.
+  EXPECT_FALSE(web_contents_impl->IsCapabilityActive(
+      WebContentsCapabilityType::kGeolocation));
+}
+
 }  // namespace
 }  // namespace content
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential silent background location tracking via unmediated Geolocation Mojo pipe

Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The browser process acts as a pass-through broker when establishing a Geolocation connection, granting a compromised renderer a direct, unmediated Mojo pipe to the Device Service. Because location UI indicators are tied to the broker connection and visibility checks are handled client-side, a malicious renderer can drop the broker connection to hide the UI indicator and continue polling location silently in the background.

Affected files:

  • content/browser/geolocation/geolocation_service_impl.cc
  • services/device/geolocation/geolocation_impl.cc
  • services/device/public/mojom/geolocation_context.mojom

Estimated timestamp from git blame: 2016-10-16

Summary

Code review indicates a potential logic flaw in how Chromium manages Geolocation API connections. The browser process (content::GeolocationServiceImpl) currently acts as a simple pass-through broker. When a renderer requests location access, the browser verifies permissions, turns on the location tracking UI indicator, and then passes a direct device::mojom::Geolocation Mojo pipe between the renderer and the unsandboxed Device Service.

This architecture creates two distinct vulnerabilities if the renderer process is compromised:

  1. UI Indicator Bypass: The location tracking visual indicator is tied to the lifetime of the broker connection (blink::mojom::GeolocationService), not the data connection. An attacker can request location access, receive the direct data pipe, and immediately close the broker connection. The browser will call GeolocationServiceImpl::OnDisconnected(), decrementing the activity count and hiding the UI indicator, but the direct data pipe remains open.
  2. Privacy/Visibility Bypass: Chromium’s privacy model dictates that backgrounded tabs should lose access to sensitive APIs. Currently, this is enforced entirely within the untrusted renderer process (blink::Geolocation::PageVisibilityChanged()). Because the browser process relinquished control of the data pipe, it cannot enforce visibility state.

Potential Exploitation Steps

Our tooling agent cannot currently run code to verify this, but code inspection suggests an attacker with a compromised renderer (and previously granted Geolocation permissions) could execute the following sequence:

  1. The compromised renderer binds the blink::mojom::GeolocationService (the broker).
  2. It calls CreateGeolocation(), passing a pending receiver for the data pipe.
  3. The browser process (GeolocationServiceImpl) checks permissions, calls IncrementActivityCount() to turn on the UI indicator, and passes the pending receiver to the Device Service via GeolocationContext::BindGeolocation().
  4. The Device Service creates a device::GeolocationImpl and establishes a direct device::mojom::Geolocation pipe with the compromised renderer.
  5. The compromised renderer immediately closes its end of the blink::mojom::GeolocationService broker pipe.
  6. The browser process detects the disconnection, calls DecrementActivityCount(), and turns off the location tracking UI indicator.
  7. The user backgrounds the tab.
  8. The compromised renderer ignores the standard PageVisibilityChanged() signal to halt tracking.
  9. The compromised renderer continuously calls QueryNextPosition() on the direct device::mojom::Geolocation pipe, achieving completely silent, persistent background location tracking.

The browser process must maintain authority over the data stream to enforce UI state and privacy boundaries.

Option 1 (Mediation): Do not pass the device::mojom::Geolocation pipe directly to the Device Service. Instead, the browser process (GeolocationServiceImpl) should own the remote to the Device Service and the receiver from the renderer. The browser process can then mediate QueryNextPosition calls, returning errors or dropping requests if the tab is backgrounded or the UI state is disconnected.

Option 2 (Signaling): If the direct pipe is required for performance, GeolocationServiceImpl must become a WebContentsObserver to monitor visibility changes. The device::mojom::GeolocationContext interface must be updated with Pause() and Resume() methods so the browser process can instruct the Device Service to halt updates for specific clients when they become hidden or disconnect their broker pipes.

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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