CVE-2026-87450
Overview
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/README.mdextensions/browser/guest_view/web_view/web_view_guest.cc
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/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 // 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) {
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.