Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in ControlledFrame
DescriptionInsufficient policy enforcement in ControlledFrame
ComponentControlledFrame
Bug ClassLogic Error
Tracker499204022
Fix commitc2140d7be335 (chromium/src) +186/-37
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
ControlledFrameUnattachedGuestPermissionRequestTest
chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
modified

Files Changed

  • chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
  • chrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.cc
From c2140d7be335b369ed840c7154dc9e7c4c27827e Mon Sep 17 00:00:00 2001
From: Giovanni Pezzino <giovax@google.com>
Date: Thu, 25 Jun 2026 03:33:45 -0700
Subject: [PATCH] ControlledFrame: deny policy-gated permissions before attach

The Controlled Frame permissions-policy check in
ChromeWebViewPermissionHelperDelegate guards the call to
IsFeatureEnabledByEmbedderPermissionsPolicy() with attached() so that
embedder_rfh() is non-null when the policy is read. When a guest created
via window.open() issues a permission request while its newwindow
attachment is still in progress, attached() is false, the conjunction
short-circuits, and the request is forwarded to the embedder without
consulting the policy. The queued permissionrequest event is delivered
after DidAttach() and the embedder's response is only checked against
the embedder's own origin.

Align the policy-gated request paths (geolocation, HID, fullscreen,
clipboard read/write, clipboard sanitized write and the PEPC media path)
with the existing media-stream behaviour: a Controlled Frame guest that
is not yet attached cannot have its embedder's policy evaluated, so the
request is denied.

Add a browser test that opens a new-window guest, holds it in the
unattached state, drives each Request*Permission entry point on its
WebViewPermissionHelper and verifies the request is rejected.

BUG=499204022
TEST=browser_tests --gtest_filter=ControlledFrameUnattachedGuestPermissionRequestTest.*
TAG=agy

Change-Id: I507ba94ffd58e7518c0313456669c71f7268e836
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8003097
Reviewed-by: Andrew Rayskiy <greengrape@google.com>
Commit-Queue: Giovanni Pezzino <giovax@google.com>
Auto-Submit: Giovanni Pezzino <giovax@google.com>
Cr-Commit-Position: refs/heads/main@{#1652296}
---

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 14d1f0cb..1fc5f10 100644
--- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
@@ -25,6 +25,7 @@
 #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/guest_view/browser/guest_view_manager.h"
 #include "components/permissions/mock_chooser_controller_view.h"
 #include "components/prefs/pref_service.h"
 #include "content/public/browser/download_manager.h"
@@ -34,6 +35,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/download_test_observer.h"
+#include "extensions/browser/guest_view/web_view/web_view_permission_helper.h"
 #include "extensions/common/extension_features.h"
 #include "services/device/public/cpp/test/fake_hid_manager.h"
 #include "services/device/public/cpp/test/scoped_geolocation_overrider.h"
@@ -699,4 +701,124 @@
       return info.param.name;
     });
 
+class ControlledFrameUnattachedGuestPermissionRequestTest
+    : public ControlledFrameTestBase {
+ public:
+  void SetUpOnMainThread() override {
+    embedded_https_test_server().ServeFilesFromSourceDirectory(
+        GetChromeTestDataDir().AppendASCII("web_apps/simple_isolated_app"));
+    ControlledFrameTestBase::SetUpOnMainThread();
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(ControlledFrameUnattachedGuestPermissionRequestTest,
+                       UnattachedNewWindowGuestDeniesPolicyGatedPermissions) {
+  auto [app_frame, controlled_frame] =
+      InstallAndOpenIwaThenCreateControlledFrame(
+          /*controlled_frame_host_name=*/std::nullopt,
+          "/controlled_frame.html");
+
+  // Trigger window.open and prevent default in newwindow event.
+  // This keeps the guest unattached.
+  auto test_script = content::JsReplace(
+      R"(
+(async function() {
+  return new Promise((resolve) => {
+    const frame = document.getElementsByTagName('controlledframe')[0];
+    frame.addEventListener('newwindow', (e) => {
+      e.preventDefault();
+      resolve('SUCCESS');
+    });
+    frame.executeScript({code: 'window.open($1);'});
+  });
+})();
+      )",
+      embedded_https_test_server().GetURL("/index.html"));
+
+  ASSERT_EQ("SUCCESS", content::EvalJs(app_frame, test_script));
+
+  // Find the unattached guest in C++.
+  content::BrowserContext* browser_context = app_frame->GetBrowserContext();
+  guest_view::GuestViewManager* manager =
+      guest_view::GuestViewManager::FromBrowserContext(browser_context);
+  ASSERT_TRUE(manager);
+
+  content::WebContents* guest_contents = nullptr;
+  content::WebContents* owner_contents =
+      content::WebContents::FromRenderFrameHost(app_frame);
+  manager->ForEachUnattachedGuestContents(
+      owner_contents,
+      [&guest_contents](content::WebContents* unattached_contents) {
+        guest_contents = unattached_contents;
+      });
+  ASSERT_TRUE(guest_contents);
+
+  auto* permission_helper =
+      extensions::WebViewPermissionHelper::FromRenderFrameHost(
+          guest_contents->GetPrimaryMainFrame());
+  ASSERT_TRUE(permission_helper);
+
+  GURL requesting_frame_url("https://attacker.test");
+  url::Origin requesting_origin = url::Origin::Create(requesting_frame_url);
+
+  // Test Geolocation
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestGeolocationPermission(
+        requesting_frame_url, /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test HID
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestHidPermission(requesting_frame_url,
+                                            future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Fullscreen
+  {
+    base::test::TestFuture<bool, const std::string&> future;
+    permission_helper->RequestFullscreenPermission(requesting_origin,
+                                                   future.GetCallback());
+    auto [allowed, user_input] = future.Get();
+    EXPECT_FALSE(allowed);
+  }
+
+  // Test Clipboard Read/Write
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestClipboardReadWritePermission(
+        requesting_frame_url, /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Clipboard Sanitized Write
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestClipboardSanitizedWritePermission(
+        requesting_frame_url, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Media (Camera)
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestMediaPermission(
+        ContentSettingsType::MEDIASTREAM_CAMERA, requesting_frame_url,
+        /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Media (Microphone)
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestMediaPermission(
+        ContentSettingsType::MEDIASTREAM_MIC, requesting_frame_url,
+        /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+}
+
 }  // namespace controlled_frame
diff --git a/chrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.cc b/chrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.cc
index 64ce276..e6e1a367 100644
--- a/chrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.cc
+++ b/chrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.cc
@@ -314,8 +314,11 @@
     base::OnceCallback<void(bool)> callback) {
   CHECK(type == ContentSettingsType::MEDIASTREAM_MIC ||
         type == ContentSettingsType::MEDIASTREAM_CAMERA);
-  if (web_view_guest()->attached() &&
-      web_view_guest()->IsOwnedByControlledFrameEmbedder()) {
+  if (web_view_guest()->IsOwnedByControlledFrameEmbedder()) {
+    if (!web_view_guest()->attached()) {
+      std::move(callback).Run(false);
+      return;
+    }
     const network::mojom::PermissionsPolicyFeature feature =
         (type == ContentSettingsType::MEDIASTREAM_MIC)
Loading diff…

Regression Test / PoC

shipped with the fix
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 14d1f0cb..1fc5f10 100644
--- a/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
+++ b/chrome/browser/controlled_frame/controlled_frame_permission_request_browsertest.cc
@@ -25,6 +25,7 @@
 #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/guest_view/browser/guest_view_manager.h"
 #include "components/permissions/mock_chooser_controller_view.h"
 #include "components/prefs/pref_service.h"
 #include "content/public/browser/download_manager.h"
@@ -34,6 +35,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/download_test_observer.h"
+#include "extensions/browser/guest_view/web_view/web_view_permission_helper.h"
 #include "extensions/common/extension_features.h"
 #include "services/device/public/cpp/test/fake_hid_manager.h"
 #include "services/device/public/cpp/test/scoped_geolocation_overrider.h"
@@ -699,4 +701,124 @@
       return info.param.name;
     });
 
+class ControlledFrameUnattachedGuestPermissionRequestTest
+    : public ControlledFrameTestBase {
+ public:
+  void SetUpOnMainThread() override {
+    embedded_https_test_server().ServeFilesFromSourceDirectory(
+        GetChromeTestDataDir().AppendASCII("web_apps/simple_isolated_app"));
+    ControlledFrameTestBase::SetUpOnMainThread();
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(ControlledFrameUnattachedGuestPermissionRequestTest,
+                       UnattachedNewWindowGuestDeniesPolicyGatedPermissions) {
+  auto [app_frame, controlled_frame] =
+      InstallAndOpenIwaThenCreateControlledFrame(
+          /*controlled_frame_host_name=*/std::nullopt,
+          "/controlled_frame.html");
+
+  // Trigger window.open and prevent default in newwindow event.
+  // This keeps the guest unattached.
+  auto test_script = content::JsReplace(
+      R"(
+(async function() {
+  return new Promise((resolve) => {
+    const frame = document.getElementsByTagName('controlledframe')[0];
+    frame.addEventListener('newwindow', (e) => {
+      e.preventDefault();
+      resolve('SUCCESS');
+    });
+    frame.executeScript({code: 'window.open($1);'});
+  });
+})();
+      )",
+      embedded_https_test_server().GetURL("/index.html"));
+
+  ASSERT_EQ("SUCCESS", content::EvalJs(app_frame, test_script));
+
+  // Find the unattached guest in C++.
+  content::BrowserContext* browser_context = app_frame->GetBrowserContext();
+  guest_view::GuestViewManager* manager =
+      guest_view::GuestViewManager::FromBrowserContext(browser_context);
+  ASSERT_TRUE(manager);
+
+  content::WebContents* guest_contents = nullptr;
+  content::WebContents* owner_contents =
+      content::WebContents::FromRenderFrameHost(app_frame);
+  manager->ForEachUnattachedGuestContents(
+      owner_contents,
+      [&guest_contents](content::WebContents* unattached_contents) {
+        guest_contents = unattached_contents;
+      });
+  ASSERT_TRUE(guest_contents);
+
+  auto* permission_helper =
+      extensions::WebViewPermissionHelper::FromRenderFrameHost(
+          guest_contents->GetPrimaryMainFrame());
+  ASSERT_TRUE(permission_helper);
+
+  GURL requesting_frame_url("https://attacker.test");
+  url::Origin requesting_origin = url::Origin::Create(requesting_frame_url);
+
+  // Test Geolocation
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestGeolocationPermission(
+        requesting_frame_url, /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test HID
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestHidPermission(requesting_frame_url,
+                                            future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Fullscreen
+  {
+    base::test::TestFuture<bool, const std::string&> future;
+    permission_helper->RequestFullscreenPermission(requesting_origin,
+                                                   future.GetCallback());
+    auto [allowed, user_input] = future.Get();
+    EXPECT_FALSE(allowed);
+  }
+
+  // Test Clipboard Read/Write
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestClipboardReadWritePermission(
+        requesting_frame_url, /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Clipboard Sanitized Write
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestClipboardSanitizedWritePermission(
+        requesting_frame_url, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Media (Camera)
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestMediaPermission(
+        ContentSettingsType::MEDIASTREAM_CAMERA, requesting_frame_url,
+        /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+
+  // Test Media (Microphone)
+  {
+    base::test::TestFuture<bool> future;
+    permission_helper->RequestMediaPermission(
+        ContentSettingsType::MEDIASTREAM_MIC, requesting_frame_url,
+        /*user_gesture=*/true, future.GetCallback());
+    EXPECT_FALSE(future.Get());
+  }
+}
+
 }  // namespace controlled_frame
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Permissions-Policy bypass in ControlledFrame via attachment race condition

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 without the security team.

Overview: A race condition during window attachment allows a ControlledFrame guest to bypass the embedder’s Permissions-Policy for sensitive APIs like Geolocation. If guest navigation finishes while attachment is delayed by pending content script operations, policy checks are skipped and the permission request is incorrectly queued and later delivered. This bypasses the browser’s declarative cross-origin restrictions.

Affected files:

  • chrome/browser/guest_view/web_view/chrome_web_view_permission_helper_delegate.cc
  • extensions/browser/guest_view/web_view/web_view_guest.cc
  • components/guest_view/browser/guest_view_base.cc

Estimated timestamp from git blame: 2025-06-06

Summary

There is a potential vulnerability in ChromeWebViewPermissionHelperDelegate where a fail-open logic error allows ControlledFrame guests to bypass their embedder’s Permissions-Policy.

Functions handling permission requests (such as RequestGeolocationPermission, RequestFullscreenPermission, RequestClipboardReadWritePermission, and RequestHidPermission) attempt to enforce the embedder’s policy using short-circuiting logic:

  if (web_view_guest()->attached() &&
      web_view_guest()->IsOwnedByControlledFrameEmbedder() &&
      !IsFeatureEnabledByEmbedderPermissionsPolicy(...)) {
    std::move(callback).Run(false);
    return;
  }

If attached() evaluates to false, the entire policy check is skipped. The method then calls web_view_permission_helper()->RequestPermission(), which queues the event in pending_events_ until the guest is attached, at which point it is dispatched to the embedder.

In the default non-MPArch implementation, GuestViewBase::AttachToOuterWebContentsFrame resumes a new window’s navigation before completing the attachment process. It sets attach_in_progress_ = true (which forces attached() to return false) and schedules DidAttach() via SignalWhenReady(). If there are any pending content script operations (addContentScripts), SignalWhenReady() delays the execution of DidAttach().

During this delay, the resumed guest navigation can commit, and the newly loaded cross-origin page can request permissions. Because attached() is false, the policy check is skipped, and the request is queued. Once attachment completes, the embedder receives the event. If the embedder approves it (assuming the browser has already enforced the declarative policy), the guest gains unauthorized access.

Potential Exploitation Steps

Note: These are suggested steps based on static code analysis; our tooling agent does not currently run live Proof of Concepts.

  1. An attacker controls a guest frame inside an Isolated Web App (IWA). The IWA has a strict Permissions-Policy (e.g., geolocation=(self)).
  2. The attacker’s guest executes window.open("https://malicious-origin.com/").
  3. The browser suspends the new window and sends a newwindow event to the embedder.
  4. The embedder handles the event and calls e.window.attach(...).
  5. To trigger the race, either the attacker (if they found a way) or the embedder application triggers an addContentScripts operation on any ControlledFrame in the same BrowserContext. This causes WebViewContentScriptManager to delay the DidAttach() callback.
  6. While attach_in_progress_ is true and DidAttach is delayed, the cross-origin navigation completes. The malicious page immediately calls navigator.geolocation.getCurrentPosition().
  7. The policy check short-circuits. The event is queued and later sent to the embedder when DidAttach finally runs.
  8. The embedder calls allow() on the permission request, resulting in a full bypass of the Permissions-Policy.

Suggested Fix

Do not fail-open if the guest is not attached. The logic should mirror RequestMediaAccessPermissionForControlledFrame, which explicitly denies requests from unattached guests:

  if (!web_view_guest()->attached()) {
    // Deny request immediately.
    return;
  }

Alternatively, evaluate the Permissions-Policy inside the response handler (e.g., OnGeolocationPermissionResponse) just before calling RequestEmbedderFramePermission, ensuring the guest’s origin is validated at the time the permission is actually granted.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.

View on issue tracker
Links in the report