Low watch CVSS 5.8 chrome Input Validation 🔧 Commit mapped

Overview

Low
Severity
5.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Bluetooth
DescriptionInsufficient validation of untrusted input in Bluetooth
ComponentBluetooth
Sub-componentBluetooth
Bug ClassInput Validation
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.
Tracker497654761
Fix commitb2b3050f4ddb (chromium/src) +118/-7
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:L/I:L/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-03-30 (per vendor advisory)
Fix landed2026-06-04 (public git)
Disclosed2026-07-29
Patch gap55 days — fix public in source before the advisory

Background

Web Bluetooth
a Blink API that lets a web page discover and communicate with GATT (Generic Attribute Profile) devices, brokered by the browser-process WebBluetoothServiceImpl.
`GetBluetoothAllowed()`
a helper on WebBluetoothServiceImpl that evaluates Permissions-Policy, feature availability, and other gating and returns a blink::mojom::WebBluetoothResult (SUCCESS when access is permitted).
Mojo IPC entry point
a method on WebBluetoothServiceImpl invoked directly by the (untrusted) renderer over Mojo; the renderer chooses which method to call and with what arguments.
`BLUETOOTH_NOT_ALLOWED`
a new WebBluetoothResult error code introduced by this patch that maps to a spec-compliant NetworkError DOMException returned to the page.

Root Cause Analysis

The permission decision produced by GetBluetoothAllowed() was only enforced at the device-chooser stage of the RequestDevice flow, not at the individual GATT IPC entry points on WebBluetoothServiceImpl (connect, GetPrimaryServices, get characteristics, get descriptors, characteristic read, and the further methods shown in the truncated hunks). Because each of these is a Mojo method the renderer can call directly, a compromised or malicious renderer could reach GATT operations without ever passing the permission gate, violating the invariant that every Bluetooth operation must be authorized by GetBluetoothAllowed() returning SUCCESS.

The fix adds an early GetBluetoothAllowed() != SUCCESS guard at the top of each entry point that, on failure, immediately invokes the operation’s callback with BLUETOOTH_NOT_ALLOWED (or the empty/nullptr result appropriate to that callback’s signature) and returns before any GATT work runs. For RequestDevice, when the result is specifically CHOOSER_NOT_SHOWN_API_GLOBALLY_DISABLED it also logs a console message to preserve the prior developer-facing behavior that the bypassed chooser path used to emit. This works because the check now precedes every sensitive code path rather than living only inside the chooser.

Key insight
The permission check was enforced in one UI-driven path (the chooser) instead of at each renderer-reachable IPC boundary; the fix hoists GetBluetoothAllowed() to the entry of every GATT operation so a renderer cannot skip authorization by calling those methods directly.

Attack Path

  1. Compromised renderer An attacker who has achieved renderer-process code execution (or a page in a context where Bluetooth should be disallowed by Permissions-Policy) holds a Mojo handle to WebBluetoothServiceImpl.
  2. Skip the chooser Instead of going through RequestDevice’s chooser, the renderer directly invokes a GATT entry point such as connect, GetPrimaryServices, or a characteristic read, none of which previously called GetBluetoothAllowed().
  3. Reach GATT operations without authorization The service proceeds into GATT logic despite the origin/policy not permitting Bluetooth access, bypassing Permissions-Policy and availability gating.
  4. Post-fix outcome With the patch, the early guard fires and the callback returns BLUETOOTH_NOT_ALLOWED (mapped to NetworkError) before any GATT work executes.

Impact Assessment

An attacker controlling a renderer could invoke Web Bluetooth GATT operations from the browser process without passing the permission/Permissions-Policy checks, gaining unauthorized access to Bluetooth functionality that the origin should not have. The advisory classifies this as insufficient validation of untrusted input in Bluetooth; no exploited-in-the-wild status is stated. Any further escalation (data exfiltration from paired devices, cross-origin impact) is beyond what the patch demonstrates and is (inferred).

Changed Functions

FunctionChangeNotes
if
content/browser/bluetooth/web_bluetooth_service_impl.cc
modified

Files Changed

  • chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
  • content/browser/bluetooth/bluetooth_metrics.h
  • content/browser/bluetooth/web_bluetooth_service_impl.cc

Audit Directions

  • Per-entry-point authorization
    For any Mojo service exposed to the renderer, verify the permission/policy check is enforced at each individual method, not only at one UI/chooser step that other methods can skip.
  • Callback-shaped early returns
    When adding a guard to a method that returns via a Mojo callback, confirm every failure path invokes the callback with a valid result of the correct arity before returning, matching each entry point’s signature.
  • Delegated-permission bypass
    Audit other capability-gated subsystems (WebHID, WebUSB, Serial) for operations reachable via IPC that assume a prior chooser/prompt established permission rather than re-checking it themselves.
From b2b3050f4ddb21ca102af7e9e819a6b735505411 Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Wed, 03 Jun 2026 17:34:38 -0700
Subject: [PATCH] bluetooth: Enforce permission checks on GATT operations

Introduce a new Mojo error code BLUETOOTH_NOT_ALLOWED that maps to a
spec-compliant NetworkError DOMException.

Enforce early GetBluetoothAllowed() permission checks on
WebBluetoothService GATT IPC entry points to prevent unauthenticated
access and return BLUETOOTH_NOT_ALLOWED when permission is denied.

Bug: 497654761
Change-Id: I50cc6887a3dcc36b7397f0d36027fe1b23dfcf22
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7891430
Reviewed-by: Nasko Oskov <nasko@chromium.org>
Reviewed-by: Jack Shira <jackshira@google.com>
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Reviewed-by: Avi Drissman <avi@chromium.org>
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641338}
---

diff --git a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
index 0f93651..bc82e81 100644
--- a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
@@ -212,6 +212,7 @@
   }
 
   void TearDownOnMainThread() override {
+    content::SetBluetoothAdapter(nullptr);
     content::SetBrowserClientForTesting(old_browser_client_);
     ControlledFrameDisabledPermissionTest::TearDownOnMainThread();
   }
diff --git a/content/browser/bluetooth/bluetooth_metrics.h b/content/browser/bluetooth/bluetooth_metrics.h
index f0d2a28..1ea6ad268 100644
--- a/content/browser/bluetooth/bluetooth_metrics.h
+++ b/content/browser/bluetooth/bluetooth_metrics.h
@@ -60,10 +60,11 @@
   kWakelock = 20,
   kUnexpectedState = 21,
   kSocketError = 22,
+  kNotAllowed = 23,
   // Note: Add new ConnectGATT outcomes immediately above this line. Make sure
   // to update the enum list in
   // tools/metrics/histograms/metadata/bluetooth/enums.xml accordingly.
-  kMaxValue = kSocketError,
+  kMaxValue = kNotAllowed,
 };
 
 // There should be a call to this function before every
@@ -157,10 +158,11 @@
   kNotPaired = 11,
   kNotSupported = 12,
   kBlocklisted = 13,
+  kNotAllowed = 14,
   // Note: Add new GATT Outcomes immediately above this line.
   // Make sure to update the enum list in
   // tools/metrics/histograms/histograms.xml accordingly.
-  kMaxValue = kBlocklisted
+  kMaxValue = kNotAllowed
 };
 
 // Values below do NOT map to UMA metric values.
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.cc b/content/browser/bluetooth/web_bluetooth_service_impl.cc
index 5d78e23..4d6a09f2 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.cc
@@ -788,6 +788,21 @@
 void WebBluetoothServiceImpl::RequestDevice(
     blink::mojom::WebBluetoothRequestDeviceOptionsPtr options,
     RequestDeviceCallback callback) {
+  blink::mojom::WebBluetoothResult allowed_result = GetBluetoothAllowed();
+  if (allowed_result != blink::mojom::WebBluetoothResult::SUCCESS) {
+    if (allowed_result == blink::mojom::WebBluetoothResult::
+                              CHOOSER_NOT_SHOWN_API_GLOBALLY_DISABLED) {
+      // Log to the developer console to maintain the same behavior when the
+      // permission check fails at the chooser side (which we now bypass via
+      // this early return).
+      render_frame_host().AddMessageToConsole(
+          blink::mojom::ConsoleMessageLevel::kInfo,
+          "Bluetooth permission has been blocked.");
+    }
+    std::move(callback).Run(allowed_result, /*device=*/nullptr);
+    return;
+  }
+
   if (base::FeatureList::IsEnabled(
           features::kWebBluetoothAllowGetAvailabilityWithBfcache)) {
     PreventBackForwardCache();
@@ -805,7 +820,7 @@
     }
     std::move(callback).Run(
         blink::mojom::WebBluetoothResult::BLUETOOTH_LOW_ENERGY_NOT_AVAILABLE,
-        nullptr /* device */);
+        /*device=*/nullptr);
     return;
   }
   RequestDeviceImpl(std::move(options), std::move(callback), GetAdapter());
@@ -868,6 +883,14 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    // Return BLUETOOTH_NOT_ALLOWED (which maps to a spec-compliant
+    // NetworkError) when permission is denied.
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::BLUETOOTH_NOT_ALLOWED);
+    return;
+  }
+
   bool is_connect_allowed = false;
   if (base::FeatureList::IsEnabled(
           features::kWebBluetoothNewPermissionsBackend)) {
@@ -927,6 +950,10 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    return;
+  }
+
   if (base::FeatureList::IsEnabled(
           blink::features::kWebBluetoothCancelConnect)) {
     auto connecting_iter = pending_connection_device_ids_.find(device_id);
@@ -955,6 +982,15 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    // Return BLUETOOTH_NOT_ALLOWED (which maps to a spec-compliant
+    // NetworkError) when permission is denied.
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::BLUETOOTH_NOT_ALLOWED,
+        /*services=*/std::nullopt);
+    return;
+  }
+
   RecordGetPrimaryServicesServices(quantity, services_uuid);
 
   if (!IsAllowedToAccessAtLeastOneService(device_id)) {
@@ -996,6 +1032,15 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    // Return BLUETOOTH_NOT_ALLOWED (which maps to a spec-compliant
+    // NetworkError) when permission is denied.
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::BLUETOOTH_NOT_ALLOWED,
+        /*characteristics=*/std::nullopt);
+    return;
+  }
+
   RecordGetCharacteristicsCharacteristic(quantity, characteristics_uuid);
 
   if (characteristics_uuid &&
@@ -1071,6 +1116,15 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    // Return BLUETOOTH_NOT_ALLOWED (which maps to a spec-compliant
+    // NetworkError) when permission is denied.
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::BLUETOOTH_NOT_ALLOWED,
+        /*descriptors=*/std::nullopt);
+    return;
+  }
+
   if (descriptors_uuid &&
       BluetoothBlocklist::Get().IsExcluded(descriptors_uuid.value())) {
     std::move(callback).Run(
@@ -1137,6 +1191,15 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    // Return BLUETOOTH_NOT_ALLOWED (which maps to a spec-compliant
+    // NetworkError) when permission is denied.
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::BLUETOOTH_NOT_ALLOWED,
+        /*value=*/{});
+    return;
+  }
+
   const CacheQueryResult query_result =
       QueryCacheForCharacteristic(characteristic_instance_id);
 
@@ -1173,6 +1236,14 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   CHECK(back_forward_cache_feature_handle_.IsValid());
 
+  if (GetBluetoothAllowed() != blink::mojom::WebBluetoothResult::SUCCESS) {
+    // Return BLUETOOTH_NOT_ALLOWED (which maps to a spec-compliant
+    // NetworkError) when permission is denied.
+    std::move(callback).Run(
+        blink::mojom::WebBluetoothResult::BLUETOOTH_NOT_ALLOWED);
+    return;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
index 0f93651..bc82e81 100644
--- a/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cc
@@ -212,6 +212,7 @@
   }
 
   void TearDownOnMainThread() override {
+    content::SetBluetoothAdapter(nullptr);
     content::SetBrowserClientForTesting(old_browser_client_);
     ControlledFrameDisabledPermissionTest::TearDownOnMainThread();
   }
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
index 1148630..6bed1682 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
@@ -268,6 +268,11 @@
     SetFakeBlueboothAdapter();
   }
 
+  void TearDownOnMainThread() override {
+    BluetoothAdapterFactoryWrapper::Get().SetBluetoothAdapterOverride(nullptr);
+    ContentBrowserTest::TearDownOnMainThread();
+  }
+
   void SetUpCommandLine(base::CommandLine* command_line) override {
     // Sets up the blink runtime feature for accessing to navigator.bluetooth.
     command_line->AppendSwitch(
@@ -573,8 +578,6 @@
   GURL url = embedded_test_server()->GetURL("/page_with_blank_iframe.html");
   EXPECT_TRUE(NavigateToURL(shell(), url));
 
-  EXPECT_CALL(*adapter(), AddObserver(_));
-
   RenderFrameHost* sub_frame = ChildFrameAt(GetWebContents(), 0);
   ASSERT_TRUE(sub_frame);
 
@@ -598,7 +601,6 @@
       console_observer.messages();
   EXPECT_EQ(messages.size(), 1u);
   EXPECT_EQ(messages.back().source_frame, sub_frame);
-  EXPECT_CALL(*adapter(), RemoveObserver(_));
 }
 
 class WebBluetoothServiceImplFencedFramesBrowserTest
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.