CVE-2026-17815
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ControlledFramePermissionStatusLeakTestchrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc |
modified | |
ControlledFramePermissionRequestPEPCTestchrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc |
modified | |
ifextensions/browser/guest_view/web_view/web_view_guest.cc |
modified |
Files Changed
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.ccextensions/browser/guest_view/web_view/web_view_guest.cc
Patch
From 43f291054ac7dadfec25f9a16bb0bb1d61838793 Mon Sep 17 00:00:00 2001 From: Giovanni Pezzino <[email protected]> Date: Mon, 22 Jun 2026 07:35:01 -0700 Subject: [PATCH] Controlled Frame: Isolate camera, microphone, and clipboard permissions This CL fixes a vulnerability where Controlled Frame guests could leak profile-scoped camera, microphone, and clipboard permission states. This CL extends WebViewGuest::OverridePermissionResult to override AUDIO_CAPTURE, VIDEO_CAPTURE, CLIPBOARD_READ_WRITE, and CLIPBOARD_SANITIZED_WRITE to ASK for Controlled Frames. This prevents the permission status query from falling back to the profile-wide HostContentSettingsMap, ensuring proper isolation. It also adds a browser test to verify that these permissions do not leak and correctly return "prompt" when queried inside the Controlled Frame, even if they are allowed or blocked in the profile. Bug: 517427352 Test: ControlledFramePermissionStatusLeakTest.PermissionsStatusDoNotLeak TAG=agy Change-Id: I11e231aeaee68df6f45c94af5a500444cc1feca9 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7977999 Reviewed-by: Andrew Rayskiy <[email protected]> Commit-Queue: Giovanni Pezzino <[email protected]> Cr-Commit-Position: refs/heads/main@{#1650303} --- diff --git a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc index a83f86f..14d1f0cb 100644 --- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc +++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc @@ -13,6 +13,7 @@ #include "base/test/values_test_util.h" #include "chrome/browser/browser_process.h" #include "chrome/browser/chrome_content_browser_client.h" +#include "chrome/browser/content_settings/host_content_settings_map_factory.h" #include "chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h" #include "chrome/browser/hid/chrome_hid_delegate.h" #include "chrome/browser/hid/hid_chooser_context_factory.h" @@ -20,6 +21,8 @@ #include "chrome/browser/ui/browser.h" #include "chrome/browser/ui/hid/hid_chooser_controller.h" #include "chrome/common/pref_names.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/download/public/common/download_item.h" #include "components/permissions/mock_chooser_controller_view.h" @@ -508,6 +511,61 @@ content::EvalJs(controlled_frame, kTestScript).ExtractString()); } +class ControlledFramePermissionStatusLeakTest : public ControlledFrameTestBase { + protected: + void SetPermission(const GURL& url, + ContentSettingsType type, + ContentSetting setting) { + HostContentSettingsMapFactory::GetForProfile(profile()) + ->SetContentSettingDefaultScope(url, url, type, setting); + } + + std::string QueryPermission(content::RenderFrameHost* frame, + const std::string& name) { + return content::EvalJs(frame, content::JsReplace(R"( + navigator.permissions.query({name: $1}).then(r => r.state); + )", + name)) + .ExtractString(); + } +}; + +IN_PROC_BROWSER_TEST_F(ControlledFramePermissionStatusLeakTest, + PermissionsStatusDoNotLeak) { + GURL guest_url = + embedded_https_test_server().GetURL("guest.com", "/empty.html"); + url::Origin guest_origin = url::Origin::Create(guest_url); + + // Set profile-wide permissions for the guest origin. + SetPermission(guest_url, ContentSettingsType::MEDIASTREAM_CAMERA, + CONTENT_SETTING_ALLOW); + SetPermission(guest_url, ContentSettingsType::MEDIASTREAM_MIC, + CONTENT_SETTING_BLOCK); + SetPermission(guest_url, ContentSettingsType::GEOLOCATION, + CONTENT_SETTING_ALLOW); + SetPermission(guest_url, ContentSettingsType::CLIPBOARD_READ_WRITE, + CONTENT_SETTING_ALLOW); + + // Install and open IWA, then create ControlledFrame pointing to the guest + // origin. + auto [app_frame, controlled_frame] = + InstallAndOpenIwaThenCreateControlledFrame( + /*controlled_frame_host_name=*/"guest.com", "/empty.html"); + ASSERT_TRUE(app_frame); + ASSERT_TRUE(controlled_frame); + ASSERT_EQ(controlled_frame->GetLastCommittedOrigin(), guest_origin); + + // Geolocation (control case, already overridden to ASK, should return + // "prompt") + EXPECT_EQ("prompt", QueryPermission(controlled_frame, "geolocation")); + + // Camera, Microphone and Clipboard should also be isolated and return + // "prompt". Before the fix, these will leak. + EXPECT_EQ("prompt", QueryPermission(controlled_frame, "camera")); + EXPECT_EQ("prompt", QueryPermission(controlled_frame, "microphone")); + EXPECT_EQ("prompt", QueryPermission(controlled_frame, "clipboard-read")); +} + class ControlledFramePermissionRequestPEPCTest : public ControlledFramePermissionRequestTest { public: 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 c1f8ae8..82ec28c 100644 --- a/extensions/browser/guest_view/web_view/web_view_guest.cc +++ b/extensions/browser/guest_view/web_view/web_view_guest.cc @@ -1654,7 +1654,11 @@ // is. const blink::PermissionType permission_type = permissions::PermissionUtil::ContentSettingsTypeToPermissionType(type); - if (permission_type == blink::PermissionType::GEOLOCATION) { + if (permission_type == blink::PermissionType::GEOLOCATION || + 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) { return content::PermissionResult( content::PermissionStatus::ASK, content::PermissionStatusSource::UNSPECIFIED);
Regression Test / PoC
diff --git a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
index a83f86f..14d1f0cb 100644
--- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
@@ -13,6 +13,7 @@
#include "base/test/values_test_util.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/chrome_content_browser_client.h"
+#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/browser/controlled_frame/controlled_frame_permission_request_test_base.h"
#include "chrome/browser/hid/chrome_hid_delegate.h"
#include "chrome/browser/hid/hid_chooser_context_factory.h"
@@ -20,6 +21,8 @@
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/hid/hid_chooser_controller.h"
#include "chrome/common/pref_names.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/download/public/common/download_item.h"
#include "components/permissions/mock_chooser_controller_view.h"
@@ -508,6 +511,61 @@
content::EvalJs(controlled_frame, kTestScript).ExtractString());
}
+class ControlledFramePermissionStatusLeakTest : public ControlledFrameTestBase {
+ protected:
+ void SetPermission(const GURL& url,
+ ContentSettingsType type,
+ ContentSetting setting) {
+ HostContentSettingsMapFactory::GetForProfile(profile())
+ ->SetContentSettingDefaultScope(url, url, type, setting);
+ }
+
+ std::string QueryPermission(content::RenderFrameHost* frame,
+ const std::string& name) {
+ return content::EvalJs(frame, content::JsReplace(R"(
+ navigator.permissions.query({name: $1}).then(r => r.state);
+ )",
+ name))
+ .ExtractString();
+ }
+};
+
+IN_PROC_BROWSER_TEST_F(ControlledFramePermissionStatusLeakTest,
+ PermissionsStatusDoNotLeak) {
+ GURL guest_url =
+ embedded_https_test_server().GetURL("guest.com", "/empty.html");
+ url::Origin guest_origin = url::Origin::Create(guest_url);
+
+ // Set profile-wide permissions for the guest origin.
+ SetPermission(guest_url, ContentSettingsType::MEDIASTREAM_CAMERA,
+ CONTENT_SETTING_ALLOW);
+ SetPermission(guest_url, ContentSettingsType::MEDIASTREAM_MIC,
+ CONTENT_SETTING_BLOCK);
+ SetPermission(guest_url, ContentSettingsType::GEOLOCATION,
+ CONTENT_SETTING_ALLOW);
+ SetPermission(guest_url, ContentSettingsType::CLIPBOARD_READ_WRITE,
+ CONTENT_SETTING_ALLOW);
+
+ // Install and open IWA, then create ControlledFrame pointing to the guest
+ // origin.
+ auto [app_frame, controlled_frame] =
+ InstallAndOpenIwaThenCreateControlledFrame(
+ /*controlled_frame_host_name=*/"guest.com", "/empty.html");
+ ASSERT_TRUE(app_frame);
+ ASSERT_TRUE(controlled_frame);
+ ASSERT_EQ(controlled_frame->GetLastCommittedOrigin(), guest_origin);
+
+ // Geolocation (control case, already overridden to ASK, should return
+ // "prompt")
+ EXPECT_EQ("prompt", QueryPermission(controlled_frame, "geolocation"));
+
+ // Camera, Microphone and Clipboard should also be isolated and return
+ // "prompt". Before the fix, these will leak.
+ EXPECT_EQ("prompt", QueryPermission(controlled_frame, "camera"));
+ EXPECT_EQ("prompt", QueryPermission(controlled_frame, "microphone"));
+ EXPECT_EQ("prompt", QueryPermission(controlled_frame, "clipboard-read"));
+}
+
class ControlledFramePermissionRequestPEPCTest
: public ControlledFramePermissionRequestTest {
public:
Original Bug Report
Controlled Frame guest leaks profile-scoped camera/microphone permission state
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 vulnerability in Chromium’s Controlled Frame permission isolation allows an embedded guest to leak profile-scoped camera and microphone permission states. This is caused by WebViewGuest::OverridePermissionResult failing to override AUDIO_CAPTURE and VIDEO_CAPTURE permissions to ASK for Controlled Frames. Consequently, permission status queries fallback to the profile-wide content settings map, leaking isolated user permissions to an embedded guest.
Affected files:
extensions/browser/guest_view/web_view/web_view_guest.cc
Estimated timestamp from git blame: 2024-08-27
Summary
A potential vulnerability in Chromium’s Controlled Frame permission isolation allows an embedded guest inside a <controlledframe> to leak profile-scoped camera and microphone permission states and enterprise permission allowlists. This bypasses the StoragePartition/embedder boundaries intended for Controlled Frames, allowing a malicious Isolated Web App (IWA) to learn about the user’s regular profile permission history and enterprise policy configuration.
Root Cause Analysis
WebViewGuest::OverridePermissionResult (defined in extensions/browser/guest_view/web_view/web_view_guest.cc, lines 1644-1665) implements Controlled Frame’s permission isolation, but it currently only handles the GEOLOCATION permission type:
std::optional<content::PermissionResult> WebViewGuest::OverridePermissionResult(
ContentSettingsType type) const {
auto result = web_view_permission_helper_->OverridePermissionResult(type);
if (result) { return result; }
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) {
return content::PermissionResult(
content::PermissionStatus::ASK,
content::PermissionStatusSource::UNSPECIFIED);
}
// Returns nullopt for unhandled cases.
}
return std::nullopt;
}
For AUDIO_CAPTURE (microphone) and VIDEO_CAPTURE (camera), this returns std::nullopt. Because the browser-layer delegate does not override these types, the permission query is allowed to proceed since IsPermissionRequestable returns true for these types. This causes the lookup to fall back to the profile-wide HostContentSettingsMap and enterprise policy lists (e.g. VideoCaptureAllowedUrls and AudioCaptureAllowedUrls), breaking the isolation boundary between the regular profile and the Controlled Frame’s StoragePartition.
Potential Exploit Path
Note: The following are potential steps, as our testing tools do not run active code.
- In a regular Chrome tab, the user navigates to a target origin (e.g.,
https://target.example) and grants camera/microphone permission. - The user installs and runs an Isolated Web App (IWA) controlled by an attacker that has the
controlled-framepermission in its manifest. - The malicious IWA programmatically creates a
<controlledframe>element pointing tohttps://target.example. - Once loaded, the malicious IWA injects a script into the guest using
controlledFrame.executeScriptto query the permission status:navigator.permissions.query({name: 'camera'}).then(r => { return r.state; }); - Due to the missing override, the query receives
'granted'or'denied'matching the user’s regular profile state, leaking this private cross-partition configuration back to the guest and the host IWA.
Suggested Fix
To correct the permission isolation, add AUDIO_CAPTURE and VIDEO_CAPTURE to the list of overridden permission types in WebViewGuest::OverridePermissionResult inside extensions/browser/guest_view/web_view/web_view_guest.cc:
const blink::PermissionType permission_type =
permissions::PermissionUtil::ContentSettingsTypeToPermissionType(type);
if (permission_type == blink::PermissionType::GEOLOCATION ||
permission_type == blink::PermissionType::AUDIO_CAPTURE ||
permission_type == blink::PermissionType::VIDEO_CAPTURE) {
return content::PermissionResult(
content::PermissionStatus::ASK,
content::PermissionStatusSource::UNSPECIFIED);
}
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.