CVE-2026-87450
Overview
Background
- `<webview>` guest
- An embedded, isolated
content::WebContentshosted by a platform app or Controlled Frame embedder whose permission requests are meant to be routed to the embedder’spermissionrequesthandler. - `OverridePermissionResult()`
- A
GuestViewBase/WebViewGuesthook that lets a guest short-circuit the normal content-settings permission lookup by returning a fixedcontent::PermissionResult. - `HostContentSettingsMap`
- The profile-scoped store of per-origin content settings (grants/denials) keyed on the requesting origin, consulted by
PermissionContextBasewhen no override forcesASK. - `PermissionStatus::ASK`
- A result that forces the permission machinery into
DecidePermission(), which for a webview reachesWebViewPermissionHelper::RequestGeolocationPermission()and the embedder.
Root Cause Analysis
In WebViewGuest::OverridePermissionResult(), the ASK override for geolocation was gated behind IsOwnedByControlledFrameEmbedder(): only guests owned by a Controlled Frame embedder returned PermissionStatus::ASK for blink::PermissionType::GEOLOCATION. For every other <webview> embedder (e.g. a platform-app guest), the function fell through without overriding, so the permission lookup resolved against the profile-scoped HostContentSettingsMap keyed on the guest’s own origin. A prior geolocation grant to that origin — established in a normal tab or by enterprise policy — was therefore honored silently, without ever entering GeolocationPermissionContextExtensions::DecidePermission() or reaching WebViewPermissionHelper::RequestGeolocationPermission() and the embedder’s permissionrequest handler. The invariant that an embedder always mediates its guest’s geolocation access was violated.
The fix computes permission_type up front and returns PermissionStatus::ASK for GEOLOCATION from every WebViewGuest before the Controlled-Frame-only branch, guaranteeing the embedder is always consulted; the remaining audio/video/clipboard overrides stay gated to Controlled Frame.
ASK override to Controlled-Frame-owned guests only, letting all other webviews inherit ambient profile-level grants keyed on the guest origin; the fix hoists the geolocation ASK return out of the IsOwnedByControlledFrameEmbedder() branch so it applies to all guests.Attack Path
- Seed a profile-level grant
A user (or enterprise policy) previously granted geolocation to some origin
Oin a regular browser tab, recordingCONTENT_SETTING_ALLOWforOin the profile’sHostContentSettingsMap. - Embed that origin in a webview
A platform app (a non-Controlled-Frame
<webview>embedder) loads originOinside a guest whose own origin isO. - Bypass the override
Because the guest is not owned by a Controlled Frame embedder,
OverridePermissionResult()does not returnASK, so the geolocation lookup resolves directly against the profile grant forO. - Silent grant without embedder mediation
Geolocation is granted without ever calling
DecidePermission()or firing the embedder’spermissionrequestevent, so the embedder cannot deny or gate access as intended.
Impact Assessment
<webview> guest that the embedder never authorized, by relying on a pre-existing profile-scoped grant (or enterprise policy) for the guest’s origin rather than the embedder’s permissionrequest decision. The advisory classifies this as incorrect authorization in Permissions; no exploited-in-the-wild status is stated. The exposure is limited to the geolocation permission for origins that already hold a profile-level grant and are embedded as webview guests; escalation beyond incorrect geolocation authorization is not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifextensions/browser/guest_view/web_view/web_view_guest.cc |
modified |
Files Changed
chrome/browser/apps/guest_view/web_view_browsertest.ccextensions/browser/guest_view/web_view/web_view_guest.ccextensions/browser/guest_view/web_view/README.md
Audit Directions
- Embedder-mediated permission overrides gated on embedder typeReview other
OverridePermissionResult()/ guest permission hooks for cases where anASK/isolation override is conditioned onIsOwnedByControlledFrameEmbedder()or similar, leaving other embedder types to fall back to profile-scoped content settings. - Guest-origin-keyed content settingsAudit permission paths for
<webview>/guest contexts that consultHostContentSettingsMapkeyed on the guest’s own origin, since ambient profile grants can leak into embedded contexts that should require embedder approval. - Permission-type coverage divergenceCheck enumerations of
blink::PermissionTypein guest override logic (geolocation vs. audio/video/clipboard) for types that are handled inconsistently across embedder classes.
Patch
From d6b18fd5db5b042f56b73b13a1b3eaf170e68d06 Mon Sep 17 00:00:00 2001 From: Giovanni Pezzino <[email protected]> Date: Thu, 06 Aug 2026 03:39:54 -0700 Subject: [PATCH] [webview] Route guest geolocation via embedder for all webviews WebViewGuest::OverridePermissionResult() only returned ASK for geolocation when the guest was owned by a Controlled Frame embedder. For other <webview> embedders the permission lookup fell through to the profile-scoped HostContentSettingsMap keyed on the guest's own origin, so a prior grant to that origin in a regular tab (or an enterprise policy) was applied without ever reaching DecidePermission() and the embedder's permissionrequest handler. Return ASK for geolocation from every WebViewGuest so that GeolocationPermissionContextExtensions::DecidePermission() and WebViewPermissionHelper::RequestGeolocationPermission() are always consulted. Controlled Frame behaviour is unchanged; the remaining Controlled-Frame-only overrides stay gated. Update the webview README accordingly and add browser tests that seed a profile-level grant for the guest origin and assert the embedder is still consulted. TAG=agy Bug: 517432155 Change-Id: I60101e39107f8650ce700529f69b1c267b5b8a89 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8193560 Commit-Queue: Giovanni Pezzino <[email protected]> Auto-Submit: Giovanni Pezzino <[email protected]> Reviewed-by: Simon Hangl <[email protected]> Cr-Commit-Position: refs/heads/main@{#1674848} --- diff --git a/extensions/browser/guest_view/web_view/README.md b/extensions/browser/guest_view/web_view/README.md index e6d8e81..65ba8a2 100644 --- a/extensions/browser/guest_view/web_view/README.md +++ b/extensions/browser/guest_view/web_view/README.md @@ -65,7 +65,7 @@ 1. Pointerlock is intercepted in [WebContentsImpl::RequestToLockPointer()](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/web_contents/web_contents_impl.cc;l=4503;drc=cc5be7150eef183a1b9a6716d42a396ab7c59733;bpv=0;bpt=1). 1. Media is intercepted in [WebContentsImpl::RequestMediaAccessPermission()](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/web_contents/web_contents_impl.cc;l=5165;drc=cc5be7150eef183a1b9a6716d42a396ab7c59733). 1. Geolocation - [GeolocationPermissionContextExtensions](https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/geolocation/geolocation_permission_context_extensions.cc;l=51;drc=1149fe5f7bedbe8187bed8d6287a1ef19eac9b5a) which inherits [GeolocationPermissionContextDelegate](https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/geolocation/geolocation_permission_context_delegate.h;l=20?q=GeolocationPermissionContextDelegate&sq=&ss=chromium%2Fchromium%2Fsrc). Intercepted in [GeolocationPermissionContextExtensions::DecidePermission()](https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/geolocation/geolocation_permission_context_extensions.cc;l=71-73;drc=cc5be7150eef183a1b9a6716d42a396ab7c59733;bpv=0;bpt=1). - 1. Geolocation also has an override where it calls [GuestViewBase::OverridePermissionResult()](https://source.chromium.org/chromium/chromium/src/+/main:components/permissions/permission_context_base.cc;l=326;drc=2046c842c9a8e7abe63a74f26e05896c15daa258) to ensure that for whatever origin, the permission look up result will always be ASK which leads into GeolocationPermissionContext::DecidePermission(). This is Controlled Frame-specific. + 1. Geolocation also has an override where it calls [GuestViewBase::OverridePermissionResult()](https://source.chromium.org/chromium/chromium/src/+/main:components/permissions/permission_context_base.cc;l=326;drc=2046c842c9a8e7abe63a74f26e05896c15daa258) to ensure that for whatever origin, the permission look up result will always be ASK which leads into GeolocationPermissionContext::DecidePermission(), ensuring the embedder is always consulted. 1. HID checks whether a [RenderFrameHost](https://source.chromium.org/chromium/chromium/src/+/main:content/public/browser/render_frame_host.h;l=138?q=RenderFrameHost%20file:.h$&ss=chromium%2Fchromium%2Fsrc) is in a WebView in [ChromeHidDelegate](https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/hid/chrome_hid_delegate.cc;l=192;drc=1149fe5f7bedbe8187bed8d6287a1ef19eac9b5a) 1. New Window is called from [WebViewGuest::CreateNewGuestWebViewWindow](https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/guest_view/web_view/web_view_guest.cc;l=685;drc=1149fe5f7bedbe8187bed8d6287a1ef19eac9b5a). Note this "newwindow" event is not from a permission request, but this is where we generate a permissionrequest event to capture it and send it to the embedder. 1. File System is called from [ChromeContentBrowserClient::AllowWorkerFileSystem](https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/chrome_content_browser_client.cc;l=3114;drc=1149fe5f7bedbe8187bed8d6287a1ef19eac9b5a) and [ContentSettingsManagerDelegate::AllowStorageAccess](https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/content_settings/content_settings_manager_delegate.cc;l=82;drc=1149fe5f7bedbe8187bed8d6287a1ef19eac9b5a) diff --git a/extensions/browser/guest_view/web_view/web_view_guest.cc b/extensions/browser/guest_view/web_view/web_view_guest.cc index 245fa84a..11abf755 100644 --- a/extensions/browser/guest_view/web_view/web_view_guest.cc +++ b/extensions/browser/guest_view/web_view/web_view_guest.cc @@ -1649,14 +1649,22 @@ return result; } + blink::PermissionType permission_type; + if (!permissions::PermissionUtil::GetPermissionType(type, &permission_type)) { + return std::nullopt; + } + + if (permission_type == blink::PermissionType::GEOLOCATION) { + return content::PermissionResult( + content::PermissionStatus::ASK, + content::PermissionStatusSource::UNSPECIFIED); + } + if (IsOwnedByControlledFrameEmbedder()) { // Permission of content within a Controlled Frame is isolated. // Therefore, Controlled Frame decides what the immediate permission result // is. - const blink::PermissionType permission_type = - permissions::PermissionUtil::ContentSettingsTypeToPermissionType(type); - if (permission_type == blink::PermissionType::GEOLOCATION || - permission_type == blink::PermissionType::AUDIO_CAPTURE || + if (permission_type == blink::PermissionType::AUDIO_CAPTURE || permission_type == blink::PermissionType::VIDEO_CAPTURE || permission_type == blink::PermissionType::CLIPBOARD_READ_WRITE || permission_type == blink::PermissionType::CLIPBOARD_SANITIZED_WRITE) { diff --git a/chrome/browser/apps/guest_view/web_view_browsertest.cc b/chrome/browser/apps/guest_view/web_view_browsertest.cc index d4aa613..bd79d6d 100644 --- a/chrome/browser/apps/guest_view/web_view_browsertest.cc +++ b/chrome/browser/apps/guest_view/web_view_browsertest.cc @@ -46,6 +46,7 @@ #include "chrome/browser/autocomplete/aim_eligibility_service_factory.h" #include "chrome/browser/bluetooth/web_bluetooth_test_utils.h" #include "chrome/browser/chrome_content_browser_client.h" +#include "chrome/browser/content_settings/host_content_settings_map_factory.h" #include "chrome/browser/devtools/devtools_window_testing.h" #include "chrome/browser/glic/host/glic_ui.h" #include "chrome/browser/glic/test_support/glic_browser_test.h" @@ -86,6 +87,9 @@ #include "chrome/test/base/chrome_test_utils.h" #include "chrome/test/base/tracing.h" #include "chrome/test/base/ui_test_utils.h" +#include "components/content_settings/core/browser/host_content_settings_map.h" +#include "components/content_settings/core/common/content_settings.h" +#include "components/content_settings/core/common/content_settings_types.h" #include "components/contextual_tasks/public/features.h" #include "components/download/public/common/download_task_runner.h" #include "components/find_in_page/find_tab_helper.h" @@ -780,6 +784,37 @@ ASSERT_TRUE(done_listener.WaitUntilSatisfied()); } + void TestHelperWithProfileGrant(const std::string& test_name, + const std::string& app_location, + ContentSettingsType permission_type, + ContentSetting setting) { + ASSERT_TRUE(InitializeEmbeddedTestServer()); + + embedded_test_server()->RegisterRequestHandler(base::BindRepeating( + &WebViewTestBase::RedirectResponseHandler, kRedirectResponsePath, + embedded_test_server()->GetURL(kRedirectResponseFullPath))); + + embedded_test_server()->RegisterRequestHandler(base::BindRepeating( + &WebViewTestBase::EmptyResponseHandler, kEmptyResponsePath)); + + embedded_test_server()->RegisterRequestHandler(base::BindRepeating( + &WebViewTestBase::UserAgentResponseHandler, + kUserAgentRedirectResponsePath, + embedded_test_server()->GetURL(kRedirectResponseFullPath))); + + embedded_test_server()->RegisterRequestHandler(base::BindRepeating( + &WebViewTestBase::CacheControlResponseHandler, kCacheResponsePath)); + + EmbeddedTestServerAcceptConnections(); + + GURL guest_origin = embedded_test_server()->GetURL("localhost", "/"); + HostContentSettingsMapFactory::GetForProfile(profile()) + ->SetContentSettingDefaultScope(guest_origin, guest_origin, + permission_type, setting); + + TestHelper(test_name, app_location, NO_TEST_SERVER); + } + // Runs media_access/allow tests. void MediaAccessAPIAllowTestHelper(const std::string& test_name); @@ -3565,6 +3600,20 @@ NEEDS_TEST_SERVER); } +IN_PROC_BROWSER_TEST_P(WebViewTest, + GeolocationAPIEmbedderHasNoAccessWithProfileGrant) { + TestHelperWithProfileGrant( + "testDenyDenies", "web_view/geolocation/embedder_has_no_permission", + ContentSettingsType::GEOLOCATION, CONTENT_SETTING_ALLOW); +} + +IN_PROC_BROWSER_TEST_P(WebViewTest, + GeolocationAPIEmbedderHasAccessDenyWithProfileGrant) { + TestHelperWithProfileGrant( + "testDeny", "web_view/geolocation/embedder_has_permission", + ContentSettingsType::GEOLOCATION, CONTENT_SETTING_ALLOW); +} + // In following GeolocationAPIEmbedderHasAccess* tests, embedder (i.e. the // platform app) has geolocation permission //
Regression Test / PoC
diff --git a/chrome/browser/apps/guest_view/web_view_browsertest.cc b/chrome/browser/apps/guest_view/web_view_browsertest.cc
index d4aa613..bd79d6d 100644
--- a/chrome/browser/apps/guest_view/web_view_browsertest.cc
+++ b/chrome/browser/apps/guest_view/web_view_browsertest.cc
@@ -46,6 +46,7 @@
#include "chrome/browser/autocomplete/aim_eligibility_service_factory.h"
#include "chrome/browser/bluetooth/web_bluetooth_test_utils.h"
#include "chrome/browser/chrome_content_browser_client.h"
+#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/browser/devtools/devtools_window_testing.h"
#include "chrome/browser/glic/host/glic_ui.h"
#include "chrome/browser/glic/test_support/glic_browser_test.h"
@@ -86,6 +87,9 @@
#include "chrome/test/base/chrome_test_utils.h"
#include "chrome/test/base/tracing.h"
#include "chrome/test/base/ui_test_utils.h"
+#include "components/content_settings/core/browser/host_content_settings_map.h"
+#include "components/content_settings/core/common/content_settings.h"
+#include "components/content_settings/core/common/content_settings_types.h"
#include "components/contextual_tasks/public/features.h"
#include "components/download/public/common/download_task_runner.h"
#include "components/find_in_page/find_tab_helper.h"
@@ -780,6 +784,37 @@
ASSERT_TRUE(done_listener.WaitUntilSatisfied());
}
+ void TestHelperWithProfileGrant(const std::string& test_name,
+ const std::string& app_location,
+ ContentSettingsType permission_type,
+ ContentSetting setting) {
+ ASSERT_TRUE(InitializeEmbeddedTestServer());
+
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &WebViewTestBase::RedirectResponseHandler, kRedirectResponsePath,
+ embedded_test_server()->GetURL(kRedirectResponseFullPath)));
+
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &WebViewTestBase::EmptyResponseHandler, kEmptyResponsePath));
+
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &WebViewTestBase::UserAgentResponseHandler,
+ kUserAgentRedirectResponsePath,
+ embedded_test_server()->GetURL(kRedirectResponseFullPath)));
+
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &WebViewTestBase::CacheControlResponseHandler, kCacheResponsePath));
+
+ EmbeddedTestServerAcceptConnections();
+
+ GURL guest_origin = embedded_test_server()->GetURL("localhost", "/");
+ HostContentSettingsMapFactory::GetForProfile(profile())
+ ->SetContentSettingDefaultScope(guest_origin, guest_origin,
+ permission_type, setting);
+
+ TestHelper(test_name, app_location, NO_TEST_SERVER);
+ }
+
// Runs media_access/allow tests.
void MediaAccessAPIAllowTestHelper(const std::string& test_name);
@@ -3565,6 +3600,20 @@
NEEDS_TEST_SERVER);
}
+IN_PROC_BROWSER_TEST_P(WebViewTest,
+ GeolocationAPIEmbedderHasNoAccessWithProfileGrant) {
+ TestHelperWithProfileGrant(
+ "testDenyDenies", "web_view/geolocation/embedder_has_no_permission",
+ ContentSettingsType::GEOLOCATION, CONTENT_SETTING_ALLOW);
+}
+
+IN_PROC_BROWSER_TEST_P(WebViewTest,
+ GeolocationAPIEmbedderHasAccessDenyWithProfileGrant) {
+ TestHelperWithProfileGrant(
+ "testDeny", "web_view/geolocation/embedder_has_permission",
+ ContentSettingsType::GEOLOCATION, CONTENT_SETTING_ALLOW);
+}
+
// In following GeolocationAPIEmbedderHasAccess* tests, embedder (i.e. the
// platform app) has geolocation permission
//
Original Bug Report
Potential Geolocation manifest permission bypass in standard <webview>
Project Fortify, 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: A potential logic flaw in permission routing allows a non-ControlledFrame <webview> to bypass the host App’s manifest permission validation and obtain precise geolocation. When a guest page requests geolocation, PermissionContextBase checks the profile-scoped HostContentSettingsMap and can immediately grant permission if the origin has a profile-wide grant, short-circuiting before manifest verification or the webview permissionrequest event.
Affected files:
components/permissions/permission_context_base.ccextensions/browser/guest_view/web_view/web_view_guest.ccchrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.ccchrome/browser/geolocation/geolocation_permission_context_extensions.cc
Estimated timestamp from git blame: 2024-08-27
Root Cause Analysis
In PermissionContextBase::RequestPermission (components/permissions/permission_context_base.cc), the profile-scoped permission status is retrieved first via GetPermissionStatus. If this status is GRANTED, execution short-circuits and immediately grants the permission, bypassing the DecidePermission path entirely:
content::PermissionResult result = GetPermissionStatus(*request_data, rfh);
bool status_ignorable = PermissionUtil::CanPermissionRequestIgnoreStatus(...);
if (!status_ignorable && (result.status == PermissionStatus::GRANTED || ... )) {
...
NotifyPermissionSet(*request_data, std::move(callback), persist, &result, ...);
return; // Short-circuit, DecidePermission is never reached
}
Normally, for GuestViews, OverridePermissionResult forces ASK to prevent this bypass and route the decision to the embedder, but it only does so if the embedder is owned by a ControlledFrame. For standard Chrome Apps or WebUI <webview>s, WebViewGuest::OverridePermissionResult returns std::nullopt:
std::optional<content::PermissionResult> WebViewGuest::OverridePermissionResult(
ContentSettingsType type) const {
auto result = web_view_permission_helper_->OverridePermissionResult(type);
if (result) { return result; }
if (IsOwnedByControlledFrameEmbedder()) {
...
if (permission_type == blink::PermissionType::GEOLOCATION) {
return content::PermissionResult(content::PermissionStatus::ASK, ...);
}
}
return std::nullopt; // Non-ControlledFrame falls through
}
Furthermore, because IsPermissionRequestable(GEOLOCATION) returns true unconditionally, the deny gate does not trigger, and a profile-scoped lookup is performed against the HostContentSettingsMap of the profile. If the guest origin already has a profile-wide grant (e.g., from a user grant in a regular tab), GetPermissionStatus returns GRANTED and short-circuits. As a result, execution never reaches DecidePermission where the embedder’s routing is implemented. This potentially allows the guest to obtain geolocation without firing the embedder’s <webview> permissionrequest event and without verifying if the embedder possesses the geolocation manifest permission.
Potential Exploitation Scenario
An attacker could potentially perform the following steps to exploit this behavior:
- Package a platform app/Chrome App whose manifest includes the
webviewpermission but explicitly lacks thegeolocationmanifest permission. - Lure a victim user who already has a profile-wide geolocation grant for a specific trusted origin (e.g.,
https://example.com). - Inside the app, instantiate a
<webview>and navigate it tohttps://example.com. - Execute a script inside the webview using
webview.executeScriptor receive apostMessagefrom the guest page that invokesnavigator.geolocation.getCurrentPosition. - The coordinates are successfully retrieved by the guest and exfiltrated to the app, bypassing the app’s lack of manifest permissions.
Note: These steps represent potential exploitation paths identified via static code analysis; our tooling does not currently have the capability to run code to produce a live proof-of-concept.
Impact
This is a confused-deputy permission escalation issue (CWE-863). An installed app lacking geolocation (or potentially audioCapture/videoCapture) manifest permissions can obtain sensitive user location or media data by loading an origin in its <webview> that already has a profile-scoped grant.
Suggested Fix
Modify WebViewGuest::OverridePermissionResult to return PermissionStatus::ASK for standard <webview>s as well for delegation-sensitive permission types like GEOLOCATION, AUDIO_CAPTURE, and VIDEO_CAPTURE. This ensures that they always route through the DecidePermission path where manifest checks and delegation logic are enforced.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.