Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in Permissions
DescriptionRace in Permissions
ComponentPermissions
Bug ClassRace
Tracker498397912
Fix commit7ec30d02754b (chromium/src) +49/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
for
chrome/browser/ui/content_settings/content_setting_bubble_model.cc
modified
TEST_F
chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
modified

Files Changed

  • chrome/browser/ui/content_settings/content_setting_bubble_model.cc
  • chrome/browser/ui/content_settings/content_setting_bubble_model.h
  • chrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
From 7ec30d02754ba02a7a8818c336ac320ca65d3084 Mon Sep 17 00:00:00 2001
From: Antonio Sartori <[email protected]>
Date: Wed, 08 Apr 2026 04:08:17 -0700
Subject: [PATCH] [permissions] Store url in ContentSettingsStorageAccessBubbleModel

This CL stores the WebContents top-level URL in the
ContentSettingsStorageAccessBubbleModel upon creation, and reuses that
URL upon CommitChanges(). This ensures that changes will be committed
for the right URL even if the page navigated away in the mean time.

Bug: 498397912
Change-Id: Iadf4f91c442612abd72243c592233c07ca4eb02b
Fixed: 498397912
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7734931
Commit-Queue: Antonio Sartori <[email protected]>
Reviewed-by: Christian Dullweber <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1611378}
---

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 45fc4ca6..9f881791 100644
--- a/chrome/browser/ui/content_settings/content_setting_bubble_model.cc
+++ b/chrome/browser/ui/content_settings/content_setting_bubble_model.cc
@@ -801,21 +801,20 @@
 ContentSettingStorageAccessBubbleModel::ContentSettingStorageAccessBubbleModel(
     Delegate* delegate,
     WebContents* web_contents)
-    : ContentSettingBubbleModel(delegate, web_contents) {
+    : ContentSettingBubbleModel(delegate, web_contents),
+      page_url_(web_contents->GetURL()) {
   RecordActionHistogram(ContentSettingsType::STORAGE_ACCESS,
                         ContentSettingBubbleAction::kOpened);
   set_title(l10n_util::GetStringUTF16(IDS_SITE_SETTINGS_TYPE_STORAGE_ACCESS));
 
   // TODO(crbug.com/40064079): Consider to add subtitles to all permissions.
   set_subtitle(url_formatter::FormatUrlForSecurityDisplay(
-      web_contents->GetURL(),
-      url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC));
+      page_url_, url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC));
 
   set_message(l10n_util::GetStringFUTF16(
       IDS_STORAGE_ACCESS_PERMISSION_BUBBLE_MESSAGE,
       url_formatter::FormatUrlForSecurityDisplay(
-          web_contents->GetURL(),
-          url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC)));
+          page_url_, url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC)));
 
   auto* page_content_settings =
       PageSpecificContentSettings::GetForFrame(&GetPage().GetMainDocument());
@@ -838,7 +837,7 @@
 
   for (const auto& entry : changed_permissions_) {
     GURL primary = entry.first.GetURL();
-    const GURL& secondary = web_contents()->GetURL();
+    const GURL& secondary = page_url_;
     ContentSetting setting =
         entry.second ? CONTENT_SETTING_ALLOW : CONTENT_SETTING_BLOCK;
     permissions::PermissionUmaUtil::ScopedRevocationReporter
diff --git a/chrome/browser/ui/content_settings/content_setting_bubble_model.h b/chrome/browser/ui/content_settings/content_setting_bubble_model.h
index 4430de1..eed8fce 100644
--- a/chrome/browser/ui/content_settings/content_setting_bubble_model.h
+++ b/chrome/browser/ui/content_settings/content_setting_bubble_model.h
@@ -576,6 +576,7 @@
                         bool is_allowed) override;
 
  private:
+  GURL page_url_;
   std::map<net::SchemefulSite, /*is_allowed*/ bool> changed_permissions_;
 };
 
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 ae12051..688eae71 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
@@ -1507,6 +1507,49 @@
                                    ContentSettingsType::STORAGE_ACCESS));
 }
 
+TEST_F(ContentSettingBubbleModelTest,
+       StorageAccessCommittedWhenNavigatingAway) {
+  const GURL page_url("https://not-example.test");
+  WebContentsTester::For(web_contents())->NavigateAndCommit(page_url);
+  auto* content_settings = PageSpecificContentSettings::GetForFrame(
+      web_contents()->GetPrimaryMainFrame());
+
+  net::SchemefulSite site(GURL("https://example.com"));
+  auto* map = HostContentSettingsMapFactory::GetForProfile(profile());
+  map->SetContentSettingDefaultScope(site.GetURL(), page_url,
+                                     ContentSettingsType::STORAGE_ACCESS,
+                                     CONTENT_SETTING_BLOCK);
+
+  content_settings->OnTwoSitePermissionChanged(
+      ContentSettingsType::STORAGE_ACCESS, site, CONTENT_SETTING_BLOCK);
+
+  std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
+      ContentSettingBubbleModel::CreateContentSettingBubbleModel(
+          nullptr, web_contents(), ContentSettingsType::STORAGE_ACCESS));
+  const ContentSettingBubbleModel::BubbleContent& bubble_content =
+      content_setting_bubble_model->bubble_content();
+
+  EXPECT_EQ(bubble_content.subtitle,
+            url_formatter::FormatUrlForSecurityDisplay(
+                page_url, url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC));
+  EXPECT_EQ(0U, bubble_content.radio_group.radio_items.size());
+  EXPECT_THAT(bubble_content.site_list,
+              UnorderedElementsAre(Pair(site, false)));
+
+  content_setting_bubble_model->OnSiteRowClicked(site, true);
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            map->GetContentSetting(site.GetURL(), page_url,
+                                   ContentSettingsType::STORAGE_ACCESS));
+  WebContentsTester::For(web_contents())
+      ->NavigateAndCommit(GURL("https://another-example.test"));
+  // Simulate a CommitChanges call, which in the real implementation is called
+  // during the widget's WindowClosing as a result of PrimaryPageChanged().
+  content_setting_bubble_model->CommitChanges();
+  EXPECT_EQ(CONTENT_SETTING_ALLOW,
+            map->GetContentSetting(site.GetURL(), page_url,
+                                   ContentSettingsType::STORAGE_ACCESS));
+}
+
 #if BUILDFLAG(IS_CHROMEOS)
 TEST_F(ContentSettingBubbleModelTest, SmartCard) {
   const GURL page_url("https://toplevel.example/");
Loading diff…

Regression Test / PoC

shipped with the fix
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 ae12051..688eae71 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
@@ -1507,6 +1507,49 @@
                                    ContentSettingsType::STORAGE_ACCESS));
 }
 
+TEST_F(ContentSettingBubbleModelTest,
+       StorageAccessCommittedWhenNavigatingAway) {
+  const GURL page_url("https://not-example.test");
+  WebContentsTester::For(web_contents())->NavigateAndCommit(page_url);
+  auto* content_settings = PageSpecificContentSettings::GetForFrame(
+      web_contents()->GetPrimaryMainFrame());
+
+  net::SchemefulSite site(GURL("https://example.com"));
+  auto* map = HostContentSettingsMapFactory::GetForProfile(profile());
+  map->SetContentSettingDefaultScope(site.GetURL(), page_url,
+                                     ContentSettingsType::STORAGE_ACCESS,
+                                     CONTENT_SETTING_BLOCK);
+
+  content_settings->OnTwoSitePermissionChanged(
+      ContentSettingsType::STORAGE_ACCESS, site, CONTENT_SETTING_BLOCK);
+
+  std::unique_ptr<ContentSettingBubbleModel> content_setting_bubble_model(
+      ContentSettingBubbleModel::CreateContentSettingBubbleModel(
+          nullptr, web_contents(), ContentSettingsType::STORAGE_ACCESS));
+  const ContentSettingBubbleModel::BubbleContent& bubble_content =
+      content_setting_bubble_model->bubble_content();
+
+  EXPECT_EQ(bubble_content.subtitle,
+            url_formatter::FormatUrlForSecurityDisplay(
+                page_url, url_formatter::SchemeDisplay::OMIT_CRYPTOGRAPHIC));
+  EXPECT_EQ(0U, bubble_content.radio_group.radio_items.size());
+  EXPECT_THAT(bubble_content.site_list,
+              UnorderedElementsAre(Pair(site, false)));
+
+  content_setting_bubble_model->OnSiteRowClicked(site, true);
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            map->GetContentSetting(site.GetURL(), page_url,
+                                   ContentSettingsType::STORAGE_ACCESS));
+  WebContentsTester::For(web_contents())
+      ->NavigateAndCommit(GURL("https://another-example.test"));
+  // Simulate a CommitChanges call, which in the real implementation is called
+  // during the widget's WindowClosing as a result of PrimaryPageChanged().
+  content_setting_bubble_model->CommitChanges();
+  EXPECT_EQ(CONTENT_SETTING_ALLOW,
+            map->GetContentSetting(site.GetURL(), page_url,
+                                   ContentSettingsType::STORAGE_ACCESS));
+}
+
 #if BUILDFLAG(IS_CHROMEOS)
 TEST_F(ContentSettingBubbleModelTest, SmartCard) {
   const GURL page_url("https://toplevel.example/");
Loading diff…

Original Bug Report

reported by [email protected]

TOCTOU in Storage Access bubble allows permission spoofing

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 Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the Storage Access API permission bubble. If a top-level page navigates while the bubble is open, the permission grant is incorrectly saved using the post-navigation URL. An attacker can exploit this to forge a Storage Access grant for their own origin, bypassing the security prompt.

Affected files:

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

Estimated timestamp from git blame: 2023-06-12

Description

There is a potential Time-of-Check to Time-of-Use (TOCTOU) logic vulnerability in how the Storage Access API (SAA) permission bubble persists user choices.

When a user opens the Storage Access bubble to allow a third-party iframe (e.g., idp.com) access to cookies on a top-level site (e.g., victim.com), the UI correctly snapshots the current URL (victim.com) to display in the bubble’s text. However, the model incorrectly reads the top-level URL dynamically when committing the changes to the user’s profile.

Specifically, in chrome/browser/ui/content_settings/content_setting_bubble_model.cc, the ContentSettingStorageAccessBubbleModel::CommitChanges() method calls:

const GURL& secondary = web_contents()->GetURL();

This occurs when the widget is closing. If a navigation commits while the bubble is still open, ContentSettingBubbleContents::PrimaryPageChanged automatically closes the widget. During the asynchronous destruction of the widget, WindowClosing() invokes CommitChanges(). At this point, web_contents()->GetURL() returns the post-navigation URL.

An attacker can exploit this by opening a popup to a trusted site, waiting for the user to toggle the permission, and immediately navigating the popup to their own malicious site. The grant is then incorrectly saved for the attacker’s site.

Potential Reproduction Steps

Note: These are suggested steps to trigger the vulnerability, based on code analysis.

  1. An attacker (evil.com) uses JavaScript to open a popup to a benign site (victim.com): let w = window.open('https://victim.com');.
  2. victim.com embeds a third-party iframe (idp.com), which calls document.requestStorageAccess(). The request is blocked, showing the SAA indicator in the omnibox.
  3. The user clicks the SAA indicator. The bubble opens, asking if idp.com can access data on victim.com.
  4. The user toggles the permission to “Allowed” but does not immediately close the bubble.
  5. Before the bubble closes, the attacker’s page uses its window reference to navigate the popup: w.location = 'https://evil.com/';.
  6. The navigation commits. PrimaryPageChanged detects the navigation and initiates the widget closure.
  7. During the widget’s WindowClosing phase, CommitChanges() is executed.
  8. CommitChanges() reads web_contents()->GetURL(), which is now https://evil.com/.
  9. A 30-day ALLOW grant is written to the HostContentSettingsMap for the pair (idp.com, evil.com) instead of (idp.com, victim.com).
  10. The attacker can now silently embed idp.com on evil.com and obtain unpartitioned cookies without a prompt.

Suggested Fix

Do not read web_contents()->GetURL() dynamically in CommitChanges(). Instead, snapshot the top-level URL in the constructor of ContentSettingStorageAccessBubbleModel (similar to how ContentSettingGeolocationBubbleModel and ContentSettingRPHBubbleModel handle it) and store it as a member variable. Use this snapshotted URL in CommitChanges() to ensure the permission is granted exclusively to the origin the user actually saw and approved in the UI.

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