CVE-2026-17906
Overview
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
WebBluetoothServiceImplthat evaluates Permissions-Policy, feature availability, and other gating and returns ablink::mojom::WebBluetoothResult(SUCCESSwhen access is permitted). - Mojo IPC entry point
- a method on
WebBluetoothServiceImplinvoked directly by the (untrusted) renderer over Mojo; the renderer chooses which method to call and with what arguments. - `BLUETOOTH_NOT_ALLOWED`
- a new
WebBluetoothResulterror code introduced by this patch that maps to a spec-compliantNetworkErrorDOMExceptionreturned 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.
GetBluetoothAllowed() to the entry of every GATT operation so a renderer cannot skip authorization by calling those methods directly.Attack Path
- 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. - 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 calledGetBluetoothAllowed(). - 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.
- Post-fix outcome
With the patch, the early guard fires and the callback returns
BLUETOOTH_NOT_ALLOWED(mapped toNetworkError) before any GATT work executes.
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/bluetooth/web_bluetooth_service_impl.cc |
modified |
Files Changed
chrome/browser/controlled_frame/controlled_frame_disabled_permission_browsertest.cccontent/browser/bluetooth/bluetooth_metrics.hcontent/browser/bluetooth/web_bluetooth_service_impl.cc
Audit Directions
- Per-entry-point authorizationFor 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 returnsWhen 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 bypassAudit 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.
Patch
From b2b3050f4ddb21ca102af7e9e819a6b735505411 Mon Sep 17 00:00:00 2001 From: Alvin Ji <[email protected]> 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 <[email protected]> Reviewed-by: Jack Shira <[email protected]> Commit-Queue: Alvin Ji <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Reviewed-by: Matt Reynolds <[email protected]> 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;
Regression Test / PoC
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