Overview

Medium
Severity
4.2
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Browser
DescriptionRace condition in Browser
ComponentBrowser
Bug ClassRace
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker523091391
Fix commitabc3ca95bb5e (chromium/src) +505/-299
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-12 (per vendor advisory)
Fix landed2026-07-28 (public git)
Disclosed2026-09-08
Patch gap42 days — fix public in source before the advisory

Background

`ContentSettingBubbleModel`
the browser-process model backing the location-bar content-setting bubbles (cookies, popups, mixed script, media, storage access, etc.), created when a bubble opens and destroyed when it closes.
`content::Page`
the object representing a single committed document tree in the browser process; a WebContents swaps its primary Page on every cross-document navigation and destroys the old one (unless it is retained in the back/forward cache).
`base::SafeRef<T>`
a non-owning reference that is validated on every dereference, turning access to an already-destroyed referent into a deterministic CHECK crash rather than a use-after-free.
`PageSpecificContentSettings::GetForPage` / `GetForFrame`
helpers that fetch the per-page content-settings state; the fix routes them through the model’s bound page instead of “whatever page the WebContents currently hosts”.

Root Cause Analysis

Before the patch, ContentSettingBubbleModel and its subclasses stored a raw WebContents* and resolved their target page lazily through GetPage() (which returned the WebContents’s current primary page) and through calls like web_contents()->GetURL() and web_contents()->GetLastCommittedURL(). Because a bubble model can outlive the page it was created for, a cross-document navigation, a back/forward-cache deactivation, or a tab teardown could swap or destroy that page while the bubble was still open and awaiting a user action (OnCustomLinkClicked, CommitChanges). When the action finally ran, the model either silently operated on the new page’s document/URL and content-settings state, or dereferenced a destroyed content::Page/WebContents — a race between the asynchronous UI action and the navigation/destruction (Bug: 523091391, class “Race”).

The fix captures a base::SafeRef<content::Page> at construction so GetPage() always denotes the original page: navigations can no longer swap the referent, all URL/state reads become GetPage().GetMainDocument().GetLastCommittedURL() and GetForPage(GetPage()), and if the page has been destroyed the SafeRef deref crashes deterministically instead of corrupting memory. The commit notes the only behavioral change is that auto-closed bubbles (closed by navigation or tab close) no longer commit changes, which is the intended consequence of binding to a fixed page.

Key insight
The core mistake was tying the bubble model to a WebContents* and re-resolving its page on demand, so the model could act on a page that had been navigated away or freed underneath it; the fix pins the model to a specific content::Page via base::SafeRef, making a swapped-page logical bug impossible and a destroyed-page access a safe crash rather than a use-after-free.

Attack Path

  1. Open a content-setting bubble Cause a page under attacker control to trigger one of the bubbles (e.g. mixed-script, popups, media, or storage-access) so a ContentSettingBubbleModel subclass is constructed holding a raw WebContents*.
  2. Keep the model alive across a navigation While the bubble/model is still live, drive a cross-document navigation or a back/forward-cache deactivation of the primary page (the regression test in chrome_back_forward_cache_browsertest.cc and mixed_content_settings_tab_helper_browsertest.cc reproduces exactly this ordering), so the original content::Page is swapped out or destroyed.
  3. Fire a deferred bubble action A user/programmatic action such as OnCustomLinkClicked (which requests a reload) or the model’s CommitChanges/destructor path then calls GetPage()/web_contents(), which now resolves to the wrong page or a freed Page/WebContents.
  4. Reach the stale-access sink The action reads GetMainDocument().GetLastCommittedURL() or PageSpecificContentSettings off the stale/destroyed page, yielding either a content-settings change applied to the wrong document or a use-after-free of the freed browser-process object.
  5. Escalation A controlled use-after-free in the browser process could be developed into memory corruption and further compromise (inferred); the patch itself only demonstrates the stale/freed-page access, not a specific read/write primitive.

Impact Assessment

An attacker who can open a content-setting bubble and race it against a navigation or page destruction gains a use-after / wrong-page access in the privileged browser process, potentially applying content-setting changes to an unintended document or dereferencing a freed content::Page/WebContents. Preconditions are that a bubble model is constructed and then a cross-document navigation, bfcache deactivation, or tab teardown occurs before the bubble’s action commits. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.2. Escalation of the freed-object access to code execution is (inferred) and not established by the patch.

Changed Functions

FunctionChangeNotes
content_type_
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified
ContentSettingMixedScriptBubbleModel
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified
block_setting_
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified
for
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified
ContentSettingCookiesBubbleModel
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified
if
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified

Files Changed

  • chrome/browser/ui/content_settings/primary_page_deactivation_helper.h
  • chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
  • chrome/browser/ui/content_settings/framebust_block_browsertest.cc
  • chrome/browser/ui/content_settings/content_setting_bubble_model_browsertest.cc
  • chrome/browser/ui/content_settings/content_setting_image_model.cc
  • chrome/browser/ui/content_settings/primary_page_deactivation_helper.cc
  • chrome/browser/ui/views/location_bar/webui_content_setting_image_control_unittest.cc
  • chrome/browser/ui/views/location_bar/content_setting_image_view.cc
  • chrome/browser/ui/views/location_bar/content_setting_image_view.h
  • chrome/browser/ui/views/location_bar/content_setting_bubble_dialog_browsertest.cc
  • chrome/browser/ui/content_settings/content_setting_bubble_model.cc
  • chrome/browser/ui/content_settings/content_setting_bubble_model.h
  • chrome/browser/ui/views/content_setting_bubble_contents_unittest.cc
  • chrome/browser/ui/content_settings/BUILD.gn
  • chrome/browser/ui/views/content_setting_bubble_contents.cc
  • content/browser/renderer_host/page_impl.cc
  • content/browser/renderer_host/page_impl.h
  • chrome/browser/chrome_back_forward_cache_browsertest.cc
  • chrome/browser/content_settings/mixed_content_settings_tab_helper_browsertest.cc
  • chrome/browser/ui/views/content_setting_bubble_contents.h
  • chrome/browser/ui/views/permissions/chip/chip_controller.cc
  • content/public/browser/page.h
  • chrome/browser/ui/content_settings/content_setting_image_model.h

Audit Directions

  • `GetForFrame(&GetPage().GetMainDocument())` and `web_contents()->GetURL()` idioms
    Callers that resolve “the current page” at use time rather than pinning the originally-targeted page are the same footgun this CL removed; audit remaining call sites that mix committed-URL reads with live-WebContents reads.
  • Objects that survive back/forward-cache deactivation or tab close
    Look for state committed on close/destruction (CommitChanges, destructors) that assumes the associated page is still primary and alive; test by opening the UI, deactivating/navigating the page into bfcache, then firing the deferred action.
From abc3ca95bb5e737e118c75b966afb8e74a63d59e Mon Sep 17 00:00:00 2001
From: Antonio Sartori <[email protected]>
Date: Tue, 28 Jul 2026 05:01:15 -0700
Subject: [PATCH] Adjust lifecycle of ContentSettingBubbleModel to strongly match a Page

This CL updates ContentSettingBubbleModel to store
base::SafeRef<content::Page> instead of WebContents*. This ensures
that the model always has a valid reference to the page it is
associated with, and makes logical bugs that could be triggered by the
ContentSettingBubbleModel surviving a cross-document navigation
basically impossible.

The only small functional effect of this change is that bubbles that
are not closed by the user (but instead automatically closed because
of a navigation or a tab being closed) will not commit changes
anymore. Before this change, this depended on whether the WebContents
died (changes committed) or navigated away (changes not committed),
which seemed confusing.

Bug: 523091391
Change-Id: I4f8609e88723359d05e92e8844d97875a5563a53
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7953760
Reviewed-by: Arthur Sonzogni <[email protected]>
Reviewed-by: Balazs Engedy <[email protected]>
Reviewed-by: Mike Wasserman <[email protected]>
Commit-Queue: Antonio Sartori <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1669366}
---
diff --git a/chrome/browser/ui/content_settings/content_setting_bubble_model.cc b/chrome/browser/ui/content_settings/content_setting_bubble_model.cc
index b6c2cfc..fcb771e1 100644
--- a/chrome/browser/ui/content_settings/content_setting_bubble_model.cc
+++ b/chrome/browser/ui/content_settings/content_setting_bubble_model.cc
@@ -214,9 +214,11 @@
 constexpr UrlIdentity::TypeSet allowed_types = {
     UrlIdentity::Type::kDefault, UrlIdentity::Type::kFile,
     UrlIdentity::Type::kIsolatedWebApp};
-constexpr UrlIdentity::FormatOptions options;
 
-std::u16string GetUrlForDisplay(Profile* profile, const GURL& url) {
+std::u16string GetUrlForDisplay(
+    Profile* profile,
+    const GURL& url,
+    const UrlIdentity::FormatOptions& options = UrlIdentity::FormatOptions()) {
   if (g_display_url_override_for_testing.value_or(false)) {
     return GetDefaultDisplayURLForTesting();  // IN-TEST
   }
@@ -289,10 +291,9 @@
 
 ContentSettingSimpleBubbleModel::ContentSettingSimpleBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents,
+    content::Page& page,
     ContentSettingsType content_type)
-    : ContentSettingBubbleModel(delegate, web_contents),
-      content_type_(content_type) {
+    : ContentSettingBubbleModel(delegate, page), content_type_(content_type) {
   SetTitle();
   SetMessage();
   SetManageText();
@@ -306,11 +307,11 @@
 
 bool ContentSettingSimpleBubbleModel::IsContentAllowed() {
   PageSpecificContentSettings* content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
 
   if (content_type() == ContentSettingsType::COOKIES) {
     ContentSetting setting;
-    GetSettingManagedByUser(web_contents()->GetLastCommittedURL(),
+    GetSettingManagedByUser(GetPage().GetMainDocument().GetLastCommittedURL(),
                             content_type(), GetProfile(), &setting);
     // We check the content setting here as well because 3PC access influences
     // the allowed/blocked status even though the icon is meant for 1PC control.
@@ -455,8 +456,7 @@
 class ContentSettingMixedScriptBubbleModel
     : public ContentSettingSimpleBubbleModel {
  public:
-  ContentSettingMixedScriptBubbleModel(Delegate* delegate,
-                                       WebContents* web_contents);
+  ContentSettingMixedScriptBubbleModel(Delegate* delegate, content::Page& page);
 
   ContentSettingMixedScriptBubbleModel(
       const ContentSettingMixedScriptBubbleModel&) = delete;
@@ -475,9 +475,9 @@
 
 ContentSettingMixedScriptBubbleModel::ContentSettingMixedScriptBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents)
+    content::Page& page)
     : ContentSettingSimpleBubbleModel(delegate,
-                                      web_contents,
+                                      page,
                                       ContentSettingsType::MIXEDSCRIPT) {
   set_custom_link_enabled(true);
   set_show_learn_more(true);
@@ -534,10 +534,10 @@
 
 ContentSettingRPHBubbleModel::ContentSettingRPHBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents,
+    content::Page& page,
     custom_handlers::ProtocolHandlerRegistry* registry)
     : ContentSettingSimpleBubbleModel(delegate,
-                                      web_contents,
+                                      page,
                                       ContentSettingsType::PROTOCOL_HANDLERS),
       registry_(registry),
       pending_handler_(
@@ -545,7 +545,7 @@
       previous_handler_(
           custom_handlers::ProtocolHandler::EmptyProtocolHandler()) {
   auto* content_settings =
-      PageSpecificContentSettingsDelegate::FromWebContents(web_contents);
+      PageSpecificContentSettingsDelegate::FromWebContents(web_contents());
   pending_handler_ = content_settings->pending_protocol_handler();
   previous_handler_ = content_settings->previous_protocol_handler();
 
@@ -570,7 +570,7 @@
   std::u16string radio_ignore_label =
       l10n_util::GetStringUTF16(IDS_REGISTER_PROTOCOL_HANDLER_IGNORE);
 
-  const GURL& url = web_contents->GetLastCommittedURL();
+  const GURL& url = GetPage().GetMainDocument().GetLastCommittedURL();
   RadioGroup radio_group;
   radio_group.url = url;
 
@@ -648,13 +648,14 @@
 
 ContentSettingSingleRadioGroup::ContentSettingSingleRadioGroup(
     Delegate* delegate,
-    WebContents* web_contents,
+    content::Page& page,
     ContentSettingsType content_type)
-    : ContentSettingSimpleBubbleModel(delegate, web_contents, content_type),
+    : ContentSettingSimpleBubbleModel(delegate, page, content_type),
       block_setting_(CONTENT_SETTING_BLOCK) {
   SetRadioGroup();
-  set_is_user_modifiable(GetSettingManagedByUser(
-      web_contents->GetURL(), content_type, GetProfile(), nullptr));
+  set_is_user_modifiable(
+      GetSettingManagedByUser(GetPage().GetMainDocument().GetLastCommittedURL(),
+                              content_type, GetProfile(), nullptr));
 }
 
 ContentSettingSingleRadioGroup::~ContentSettingSingleRadioGroup() = default;
@@ -679,7 +680,7 @@
 // Initialize the radio group by setting the appropriate labels for the
 // content type and setting the default value based on the content setting.
 void ContentSettingSingleRadioGroup::SetRadioGroup() {
-  const GURL& url = web_contents()->GetURL();
+  const GURL& url = GetPage().GetMainDocument().GetLastCommittedURL();
   const std::u16string& display_url = GetUrlForDisplay(GetProfile(), url);
   bool allowed = IsContentAllowed();
 
@@ -692,8 +693,8 @@
   }
 
   DCHECK(!allowed ||
-         PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument())
-             ->IsContentAllowed(content_type()));
+         PageSpecificContentSettings::GetForPage(GetPage())->IsContentAllowed(
+             content_type()));
 
   RadioGroup radio_group;
   radio_group.url = url;
@@ -813,24 +814,26 @@
 
 ContentSettingStorageAccessBubbleModel::ContentSettingStorageAccessBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents)
-    : ContentSettingBubbleModel(delegate, web_contents),
-      page_url_(web_contents->GetURL()) {
+    content::Page& page)
+    : ContentSettingBubbleModel(delegate, page) {
   RecordActionHistogram(ContentSettingsType::STORAGE_ACCESS,
                         ContentSettingBubbleAction::kOpened);
   set_title(l10n_util::GetStringUTF16(IDS_SITE_SETTINGS_TYPE_STORAGE_ACCESS));
 
+  const std::u16string& display_url = GetUrlForDisplay(
+      GetProfile(), GetPage().GetMainDocument().GetLastCommittedURL(),
+      UrlIdentity::FormatOptions{
+          .default_options = {
+              UrlIdentity::DefaultFormatOptions::kOmitCryptographicScheme}});
+
   // TODO(crbug.com/40064079): Consider to add subtitles to all permissions.
-  set_subtitle(url_formatter::FormatUrlForSecurityDisplay(
-      page_url_, url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC));
+  set_subtitle(display_url);
 
   set_message(l10n_util::GetStringFUTF16(
-      IDS_STORAGE_ACCESS_PERMISSION_BUBBLE_MESSAGE,
-      url_formatter::FormatUrlForSecurityDisplay(
-          page_url_, url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC)));
+      IDS_STORAGE_ACCESS_PERMISSION_BUBBLE_MESSAGE, display_url));
 
   auto* page_content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
   set_site_list(page_content_settings->GetTwoSiteRequests(
       ContentSettingsType::STORAGE_ACCESS));
 
@@ -850,7 +853,7 @@
 
   for (const auto& entry : changed_permissions_) {
     GURL primary = entry.first.GetURL();
-    const GURL& secondary = page_url_;
+    const GURL& secondary = GetPage().GetMainDocument().GetLastCommittedURL();
     ContentSetting setting =
         entry.second ? CONTENT_SETTING_ALLOW : CONTENT_SETTING_BLOCK;
     permissions::PermissionUmaUtil::ScopedRevocationReporter
@@ -892,8 +895,7 @@
 
 class ContentSettingCookiesBubbleModel : public ContentSettingSingleRadioGroup {
  public:
-  ContentSettingCookiesBubbleModel(Delegate* delegate,
-                                   WebContents* web_contents);
+  ContentSettingCookiesBubbleModel(Delegate* delegate, content::Page& page);
 
   ContentSettingCookiesBubbleModel(const ContentSettingCookiesBubbleModel&) =
       delete;
@@ -909,9 +911,9 @@
 
 ContentSettingCookiesBubbleModel::ContentSettingCookiesBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents)
+    content::Page& page)
     : ContentSettingSingleRadioGroup(delegate,
-                                     web_contents,
+                                     page,
                                      ContentSettingsType::COOKIES) {}
 
 ContentSettingCookiesBubbleModel::~ContentSettingCookiesBubbleModel() = default;
@@ -942,7 +944,7 @@
     : public ContentSettingSingleRadioGroup,
       public blocked_content::UrlListManager::Observer {
  public:
-  ContentSettingPopupBubbleModel(Delegate* delegate, WebContents* web_contents);
+  ContentSettingPopupBubbleModel(Delegate* delegate, content::Page& page);
 
   ContentSettingPopupBubbleModel(const ContentSettingPopupBubbleModel&) =
       delete;
@@ -971,15 +973,15 @@
 
 ContentSettingPopupBubbleModel::ContentSettingPopupBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents)
+    content::Page& page)
     : ContentSettingSingleRadioGroup(delegate,
-                                     web_contents,
+                                     page,
                                      ContentSettingsType::POPUPS) {
   set_title(l10n_util::GetStringUTF16(IDS_BLOCKED_POPUPS_TITLE));
 
   // Build blocked popup list.
   auto* helper =
-      blocked_content::PopupBlockerTabHelper::FromWebContents(web_contents);
+      blocked_content::PopupBlockerTabHelper::FromWebContents(web_contents());
   std::map<int32_t, GURL> blocked_popups = helper->GetBlockedPopupRequests();
   for (const auto& blocked_popup : blocked_popups) {
     AddListItem(CreateUrlListItem(blocked_popup.first, blocked_popup.second));
@@ -1022,8 +1024,8 @@
 
 ContentSettingMediaStreamBubbleModel::ContentSettingMediaStreamBubbleModel(
     Delegate* delegate,
-    WebContents* web_contents)
-    : ContentSettingBubbleModel(delegate, web_contents) {
+    content::Page& page)
+    : ContentSettingBubbleModel(delegate, page) {
   // TODO(msramek): The media bubble has three states - mic only, camera only,
   // and both. There is a lot of duplicated code which does the same thing
   // for camera and microphone separately. Consider refactoring it to avoid
@@ -1035,7 +1037,7 @@
   radio_item_setting_[1] = CONTENT_SETTING_BLOCK;
 
   PageSpecificContentSettings* content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
   state_ = content_settings->GetMicrophoneCameraState();
   CHECK(CameraAccessed() || MicrophoneAccessed());
 
@@ -1049,6 +1051,10 @@
         ContentSettingsType::MEDIASTREAM_MIC);
   }
 
+  if (content_settings->media_stream_access_origin().is_empty()) {
+    return;
+  }
+
   // If the permission is turned off in MacOS system preferences, overwrite
   // the bubble to enable the user to trigger the system dialog.
   if (ShouldShowSystemMediaPermissions()) {
@@ -1071,7 +1077,7 @@
 
 void ContentSettingMediaStreamBubbleModel::CommitChanges() {
   PageSpecificContentSettings* content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
 
   if (CameraAccessed()) {
     content_settings->OnActivityIndicatorBubbleClosed(
@@ -1083,10 +1089,6 @@
         ContentSettingsType::MEDIASTREAM_MIC);
   }
 
-  if (content_settings->media_stream_access_origin().is_empty()) {
-    return;
-  }
-
   // No need for radio group in the bubble UI shown when permission is blocked
   // on a system level.
   if (!ShouldShowSystemMediaPermissions()) {
@@ -1158,7 +1160,7 @@
 void ContentSettingMediaStreamBubbleModel::SetIsUserModifiable() {
   CHECK(CameraAccessed() || MicrophoneAccessed());
   PageSpecificContentSettings* page_content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
 
   bool is_camera_modifiable = GetSettingManagedByUser(
       page_content_settings->media_stream_access_origin(),
@@ -1215,7 +1217,7 @@
 
 void ContentSettingMediaStreamBubbleModel::SetRadioGroup() {
   PageSpecificContentSettings* content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
   GURL url = content_settings->media_stream_access_origin();
   RadioGroup radio_group;
   radio_group.url = url;
@@ -1396,7 +1398,7 @@
 
 void ContentSettingMediaStreamBubbleModel::SetCustomLink() {
   PageSpecificContentSettings* content_settings =
-      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
+      PageSpecificContentSettings::GetForPage(GetPage());
   if (content_settings->IsMicrophoneCameraStateChanged()) {
     set_custom_link(
         l10n_util::GetStringUTF16(IDS_MEDIASTREAM_SETTING_CHANGED_MESSAGE));
@@ -1407,9 +1409,9 @@
 
 ContentSettingGeolocationBubbleModel::ContentSettingGeolocationBubbleModel(
     Delegate* delegate,
-    content::WebContents* web_contents)
+    content::Page& page)
     : ContentSettingSingleRadioGroup(delegate,
-                                     web_contents,
+                                     page,
                                      ContentSettingsType::GEOLOCATION) {
   SetCustomLink();
   // Get the stored geolocation content setting and the system permission
@@ -1422,7 +1424,7 @@
   // granted. We need to distinguish these cases to ensure the bubble that
   // launches the system dialog is not shown if the site-level permission was
   // not granted.
-  const GURL& url = web_contents->GetPrimaryMainFrame()->GetLastCommittedURL();
+  const GURL& url = GetPage().GetMainDocument().GetLastCommittedURL();
   ContentSetting content_setting =
       HostContentSettingsMapFactory::GetForProfile(GetProfile())
           ->GetContentSetting(url, url, ContentSettingsType::GEOLOCATION);
@@ -1501,9 +1503,9 @@
 #if BUILDFLAG(IS_MAC)
 ContentSettingNotificationsBubbleModel::ContentSettingNotificationsBubbleModel(
     Delegate* delegate,
-    content::WebContents* web_contents)
+    content::Page& page)
     : ContentSettingSimpleBubbleModel(delegate,
-                                      web_contents,
+                                      page,
                                       ContentSettingsType::NOTIFICATIONS) {
   set_title(l10n_util::GetStringUTF16(IDS_NOTIFICATIONS_TURNED_OFF_IN_MACOS));
   AddListItem(ContentSettingBubbleModel::ListItem(
@@ -1537,12 +1539,8 @@
 
 ContentSettingSubresourceFilterBubbleModel::
     ContentSettingSubresourceFilterBubbleModel(Delegate* delegate,
-                                               WebContents* web_contents)
-    : ContentSettingBubbleModel(delegate, web_contents),
-      page_(web_contents->GetPrimaryPage().GetWeakPtr()),
-      page_url_(web_contents->GetPrimaryPage()
-                    .GetMainDocument()
-                    .GetLastCommittedURL()) {
+                                               content::Page& page)
+    : ContentSettingBubbleModel(delegate, page) {
   SetTitle();
   SetMessage();
   SetManageText();
@@ -1584,14 +1582,14 @@
 
 void ContentSettingSubresourceFilterBubbleModel::CommitChanges() {
   if (is_checked_) {
-    if (page_ && page_->IsPrimary()) {
+    if (GetPage().IsPrimary()) {
       subresource_filter::ContentSubresourceFilterThrottleManager::FromPage(
-          *page_)
+          GetPage())
           ->OnReloadRequested();
     } else {
       subresource_filter::SubresourceFilterContentSettingsManager(
           HostContentSettingsMapFactory::GetForProfile(GetProfile()))
-          .AllowlistSite(page_url_);
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/chrome_back_forward_cache_browsertest.cc b/chrome/browser/chrome_back_forward_cache_browsertest.cc
index a690b19..9b2ea491 100644
--- a/chrome/browser/chrome_back_forward_cache_browsertest.cc
+++ b/chrome/browser/chrome_back_forward_cache_browsertest.cc
@@ -385,12 +385,18 @@
   // to run.
   content::TestNavigationObserver observer(
       browser()->tab_strip_model()->GetActiveWebContents());
-  std::unique_ptr<ContentSettingBubbleModel> model(
-      ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-          browser()->GetFeatures().content_setting_bubble_model_delegate(),
-          browser()->tab_strip_model()->GetActiveWebContents(),
-          ContentSettingsType::MIXEDSCRIPT));
-  model->OnCustomLinkClicked();
+
+  {
+    std::unique_ptr<ContentSettingBubbleModel> model(
+        ContentSettingBubbleModel::CreateContentSettingBubbleModel(
+            browser()->GetFeatures().content_setting_bubble_model_delegate(),
+            browser()
+                ->tab_strip_model()
+                ->GetActiveWebContents()
+                ->GetPrimaryPage(),
+            ContentSettingsType::MIXEDSCRIPT));
+    model->OnCustomLinkClicked();
+  }
 
   // 3) Wait for reload.
   observer.Wait();
diff --git a/chrome/browser/content_settings/mixed_content_settings_tab_helper_browsertest.cc b/chrome/browser/content_settings/mixed_content_settings_tab_helper_browsertest.cc
index 2827a61..f0e71b38 100644
--- a/chrome/browser/content_settings/mixed_content_settings_tab_helper_browsertest.cc
+++ b/chrome/browser/content_settings/mixed_content_settings_tab_helper_browsertest.cc
@@ -72,11 +72,14 @@
   // Emulates link clicking on the mixed script bubble to allow mixed content
   // to run.
   content::TestNavigationObserver observer(web_contents());
-  std::unique_ptr<ContentSettingBubbleModel> model(
-      ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-          browser()->GetFeatures().content_setting_bubble_model_delegate(),
-          web_contents(), ContentSettingsType::MIXEDSCRIPT));
-  model->OnCustomLinkClicked();
+  {
+    std::unique_ptr<ContentSettingBubbleModel> model(
+        ContentSettingBubbleModel::CreateContentSettingBubbleModel(
+            browser()->GetFeatures().content_setting_bubble_model_delegate(),
+            web_contents()->GetPrimaryPage(),
+            ContentSettingsType::MIXEDSCRIPT));
+    model->OnCustomLinkClicked();
+  }
 
   // Waits for reload.
   observer.Wait();
@@ -146,12 +149,14 @@
   // to run.
   content::TestNavigationObserver observer(
       browser()->tab_strip_model()->GetActiveWebContents());
-  std::unique_ptr<ContentSettingBubbleModel> model(
-      ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-          browser()->GetFeatures().content_setting_bubble_model_delegate(),
-          browser()->tab_strip_model()->GetActiveWebContents(),
-          ContentSettingsType::MIXEDSCRIPT));
-  model->OnCustomLinkClicked();
+  {
+    std::unique_ptr<ContentSettingBubbleModel> model(
+        ContentSettingBubbleModel::CreateContentSettingBubbleModel(
+            browser()->GetFeatures().content_setting_bubble_model_delegate(),
+            web_contents()->GetPrimaryPage(),
+            ContentSettingsType::MIXEDSCRIPT));
+    model->OnCustomLinkClicked();
+  }
 
   // Waits for reload.
   observer.Wait();
@@ -314,12 +319,15 @@
   // to run.
   content::TestNavigationObserver observer(
       browser()->tab_strip_model()->GetActiveWebContents());
-  std::unique_ptr<ContentSettingBubbleModel> model(
-      ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-          browser()->GetFeatures().content_setting_bubble_model_delegate(),
-          browser()->tab_strip_model()->GetActiveWebContents(),
-          ContentSettingsType::MIXEDSCRIPT));
-  model->OnCustomLinkClicked();
+
+  {
+    std::unique_ptr<ContentSettingBubbleModel> model(
+        ContentSettingBubbleModel::CreateContentSettingBubbleModel(
+            browser()->GetFeatures().content_setting_bubble_model_delegate(),
+            web_contents()->GetPrimaryPage(),
+            ContentSettingsType::MIXEDSCRIPT));
+    model->OnCustomLinkClicked();
+  }
 
   // Waits for reload.
   observer.Wait();
diff --git a/chrome/browser/ui/content_settings/content_setting_bubble_model_browsertest.cc b/chrome/browser/ui/content_settings/content_setting_bubble_model_browsertest.cc
index 4c48aa2..6cd61e8 100644
--- a/chrome/browser/ui/content_settings/content_setting_bubble_model_browsertest.cc
+++ b/chrome/browser/ui/content_settings/content_setting_bubble_model_browsertest.cc
@@ -75,7 +75,7 @@
                                      state);
     return std::make_unique<ContentSettingMediaStreamBubbleModel>(
         browser()->GetFeatures().content_setting_bubble_model_delegate(),
-        web_contents);
+        web_contents->GetPrimaryPage());
   }
 
   content::WebContents* GetActiveTab() {
@@ -185,7 +185,7 @@
   std::unique_ptr<ContentSettingBubbleModel> mic_bubble =
       std::make_unique<ContentSettingMediaStreamBubbleModel>(
           browser()->GetFeatures().content_setting_bubble_model_delegate(),
-          web_contents);
+          web_contents->GetPrimaryPage());
 
   EXPECT_TRUE(mic_bubble->bubble_content().is_user_modifiable);
 }
@@ -344,7 +344,10 @@
   std::unique_ptr<ContentSettingBubbleModel> model(
       ContentSettingBubbleModel::CreateContentSettingBubbleModel(
           browser()->GetFeatures().content_setting_bubble_model_delegate(),
-          browser()->tab_strip_model()->GetActiveWebContents(),
+          browser()
+              ->tab_strip_model()
+              ->GetActiveWebContents()
+              ->GetPrimaryPage(),
           ContentSettingsType::POPUPS));
   std::unique_ptr<FakeOwner> owner =
       FakeOwner::Create(*model, kDisallowButtonIndex);
diff --git a/chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc b/chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
index 9bba1d9..1bda534 100644
--- a/chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
+++ b/chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
@@ -96,6 +96,7 @@
         web_contents());
   }
 
+  content::Page& page() { return web_contents()->GetPrimaryPage(); }
   TestingProfile::TestingFactories GetTestingFactories() const override {
     return {TestingProfile::TestingFactory{
         HistoryServiceFactory::GetInstance(),
@@ -113,7 +114,7 @@
 
   std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
       ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-          nullptr, web_contents(), ContentSettingsType::IMAGES));
+          nullptr, page(), ContentSettingsType::IMAGES));
   const ContentSettingBubbleModel::BubbleContent& bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_FALSE(bubble_content.title.empty());
@@ -165,7 +166,7 @@
   VerifyBubbleContent(
       CONTENT_SETTING_BLOCK,
       ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-          nullptr, web_contents(), ContentSettingsType::COOKIES)
+          nullptr, page(), ContentSettingsType::COOKIES)
           ->bubble_content());
 }
 
@@ -191,7 +192,7 @@
   content_settings->OnContentAllowed(ContentSettingsType::COOKIES);
   VerifyBubbleContent(
       site_setting, ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-                        nullptr, web_contents(), ContentSettingsType::COOKIES)
+                        nullptr, page(), ContentSettingsType::COOKIES)
                         ->bubble_content());
 
   // Even if cookies are allowed on the 1P site, it's still possible for
@@ -200,7 +201,7 @@
   content_settings->OnContentBlocked(ContentSettingsType::COOKIES);
   VerifyBubbleContent(
       site_setting, ContentSettingBubbleModel::CreateContentSettingBubbleModel(
-                        nullptr, web_contents(), ContentSettingsType::COOKIES)
+                        nullptr, page(), ContentSettingsType::COOKIES)
                         ->bubble_content());
 }
 
@@ -237,7 +238,7 @@
                                                microphone_camera_state);
 
   std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-      new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+      new ContentSettingMediaStreamBubbleModel(nullptr, page()));
   const ContentSettingBubbleModel::BubbleContent& bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(bubble_content.title,
@@ -287,7 +288,7 @@
   content_settings->OnMediaStreamPermissionSet(url, microphone_camera_state);
 
   std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-      new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+      new ContentSettingMediaStreamBubbleModel(nullptr, page()));
   const ContentSettingBubbleModel::BubbleContent& bubble_content =
       content_setting_bubble_model->bubble_content();
   // Test if the correct radio item is selected for the blocked mediastream
@@ -344,7 +345,7 @@
   content_settings->OnMediaStreamPermissionSet(url, microphone_camera_state);
   {
     std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-        new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+        new ContentSettingMediaStreamBubbleModel(nullptr, page()));
     const ContentSettingBubbleModel::BubbleContent& bubble_content =
         content_setting_bubble_model->bubble_content();
     // Test if the correct radio item is selected for the blocked mediastream
@@ -363,7 +364,7 @@
 
   {
     std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-        new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+        new ContentSettingMediaStreamBubbleModel(nullptr, page()));
     const ContentSettingBubbleModel::BubbleContent& bubble_content =
         content_setting_bubble_model->bubble_content();
     // Test that the reload hint is displayed.
@@ -386,7 +387,7 @@
 
   {
     std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-        new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+        new ContentSettingMediaStreamBubbleModel(nullptr, page()));
     const ContentSettingBubbleModel::BubbleContent& bubble_content =
         content_setting_bubble_model->bubble_content();
     // Test that the reload hint is not displayed any more.
@@ -427,7 +428,7 @@
                                                microphone_camera_state);
 
   std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-      new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+      new ContentSettingMediaStreamBubbleModel(nullptr, page()));
   const ContentSettingBubbleModel::BubbleContent& bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(bubble_content.title,
@@ -451,8 +452,7 @@
   content_settings->OnMediaStreamPermissionSet(security_origin,
                                                microphone_camera_state);
   content_setting_bubble_model =
-      std::make_unique<ContentSettingMediaStreamBubbleModel>(nullptr,
-                                                             web_contents());
+      std::make_unique<ContentSettingMediaStreamBubbleModel>(nullptr, page());
   const ContentSettingBubbleModel::BubbleContent& new_bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(new_bubble_content.title,
@@ -502,7 +502,7 @@
                                                microphone_camera_state);
 
   std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-      new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+      new ContentSettingMediaStreamBubbleModel(nullptr, page()));
   const ContentSettingBubbleModel::BubbleContent& bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(bubble_content.title,
@@ -526,8 +526,7 @@
   content_settings->OnMediaStreamPermissionSet(security_origin,
                                                microphone_camera_state);
   content_setting_bubble_model =
-      std::make_unique<ContentSettingMediaStreamBubbleModel>(nullptr,
-                                                             web_contents());
+      std::make_unique<ContentSettingMediaStreamBubbleModel>(nullptr, page());
   const ContentSettingBubbleModel::BubbleContent& new_bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(new_bubble_content.title,
@@ -582,7 +581,7 @@
                                                microphone_camera_state);
 
   std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
-      new ContentSettingMediaStreamBubbleModel(nullptr, web_contents()));
+      new ContentSettingMediaStreamBubbleModel(nullptr, page()));
   const ContentSettingBubbleModel::BubbleContent& bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(bubble_content.title,
@@ -604,8 +603,7 @@
                                                microphone_camera_state);
 
   content_setting_bubble_model =
-      std::make_unique<ContentSettingMediaStreamBubbleModel>(nullptr,
-                                                             web_contents());
+      std::make_unique<ContentSettingMediaStreamBubbleModel>(nullptr, page());
   const ContentSettingBubbleModel::BubbleContent& new_bubble_content =
       content_setting_bubble_model->bubble_content();
   EXPECT_EQ(new_bubble_content.title,
@@ -679,8 +677,7 @@
   // System-level geolocation permission is blocked.
   if (is_os_level_geolocation_permission_support_enabled) {
     auto content_setting_bubble_model =
-        std::make_unique<ContentSettingGeolocationBubbleModel>(nullptr,
-                                                               web_contents());
+        std::make_unique<ContentSettingGeolocationBubbleModel>(nullptr, page());
     std::unique_ptr<FakeOwner> owner =
         FakeOwner::Create(*content_setting_bubble_model, 0);
     const auto& bubble_content = content_setting_bubble_model->bubble_content();
@@ -700,8 +697,7 @@
   // bubble is visible. The displayed message should not change.
   if (is_os_level_geolocation_permission_support_enabled) {
     auto content_setting_bubble_model =
-        std::make_unique<ContentSettingGeolocationBubbleModel>(nullptr,
-                                                               web_contents());
+        std::make_unique<ContentSettingGeolocationBubbleModel>(nullptr, page());
     std::unique_ptr<FakeOwner> owner =
         FakeOwner::Create(*content_setting_bubble_model, 0);
     const auto& bubble_content = content_setting_bubble_model->bubble_content();
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Potential Permission Revocation Bypass in ContentSettingMediaStreamBubble

Flapjack, 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 logic error in the media stream permission bubble allows a site to prevent a user from revoking camera or microphone access. If the site triggers a cross-document navigation while the bubble is open, the browser’s revocation logic executes against the new page state, silently discarding the user’s explicit intent to block permissions.

Affected files:

  • chrome/browser/ui/content_settings/content_setting_bubble_model.cc

Estimated timestamp from git blame: 2023-07-19

Summary

The ContentSettingMediaStreamBubbleModel manages the omnibox permission bubble for Camera and Microphone access. A potential vulnerability exists because the user’s decision to “Block” access is not applied immediately; it is deferred until the bubble closes via CommitChanges(). However, CommitChanges() fetches the page state using GetPage().GetMainDocument(), which refers to the current primary page of the associated WebContents.

If a cross-document navigation commits while the bubble is open, the WebContentsObserver::PrimaryPageChanged event fires, causing the bubble to close synchronously. During the destruction sequence, CommitChanges() is invoked, but GetPage() now points to the newly committed page. Because the new page has not yet requested media streams, its PageSpecificContentSettings has an empty media_stream_access_origin. This causes CommitChanges() to hit an early return, entirely skipping the code that updates the user’s content settings.

Potential Attack Scenario

An attacker who has already been granted Camera/Microphone access could use this to maintain persistent access despite explicit user intervention to revoke it. (Note: These are suggested steps based on static analysis; our tooling cannot run live code).

  1. The user grants Camera/Mic permissions to https://attacker.com.
  2. The malicious page listens for the window.onblur event. When the user clicks the omnibox media indicator, the permission bubble steals focus, triggering the event.
  3. The malicious page reacts to onblur by initiating a delayed cross-document navigation (e.g., location.href = '/delayed_response').
  4. The user clicks the radio button to “Block” the camera/microphone.
  5. Before the user clicks “Done” or closes the bubble manually, the delayed navigation commits.
  6. The browser invokes WebContentsImpl::DidCommitNavigation and updates the primary page.
  7. WebContentsObserver::PrimaryPageChanged is fired. ContentSettingBubbleContents receives this and calls GetWidget()->Close() to dismiss the bubble since the page changed.
  8. The widget destruction sequence calls ContentSettingBubbleContents::WindowClosing(), which invokes content_setting_bubble_model_->CommitChanges().
  9. CommitChanges() calls PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument()). This retrieves the settings for the new page.
  10. The code evaluates if (content_settings->media_stream_access_origin().is_empty()). Since the new page hasn’t accessed media, this is true.
  11. The function returns early. UpdateSettings() is never called, and the user’s intent to revoke the permission is silently discarded.

Code Reference

In chrome/browser/ui/content_settings/content_setting_bubble_model.cc:

void ContentSettingMediaStreamBubbleModel::CommitChanges() {
  PageSpecificContentSettings* content_settings =
      PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
  // ...
  if (content_settings->media_stream_access_origin().is_empty()) {
    return; // <--- Early return hit because of the new page state
  }
  // ...
  if (!ShouldShowSystemMediaPermissions()) {
    if (selected_item() != bubble_content().radio_group.default_item) {
      UpdateSettings(radio_item_setting_[selected_item()]); // <--- Revocation is skipped
    }
  }
}

Suggested Fix

Instead of querying PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument()) during CommitChanges(), the ContentSettingMediaStreamBubbleModel should cache the media_stream_access_origin (or the specific url to be modified) when the model is constructed. This ensures that the user’s action is applied to the origin that was actively using the media stream when the bubble was opened, regardless of subsequent navigations.

Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff


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.

View on issue tracker
Links in the report