CVE-2026-11253
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/browser/ui/content_settings/content_setting_bubble_model.cc |
modified | |
TEST_Fchrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc |
modified |
Files Changed
chrome/browser/ui/content_settings/content_setting_bubble_model.ccchrome/browser/ui/content_settings/content_setting_bubble_model.hchrome/browser/ui/content_settings/content_setting_bubble_model_unittest.cc
Patch
From 7ec30d02754ba02a7a8818c336ac320ca65d3084 Mon Sep 17 00:00:00 2001
From: Antonio Sartori <antoniosartori@chromium.org>
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 <antoniosartori@chromium.org>
Reviewed-by: Christian Dullweber <dullweber@chromium.org>
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/");
Regression Test / PoC
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/");
Original Bug Report
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.
- An attacker (
evil.com) uses JavaScript to open a popup to a benign site (victim.com):let w = window.open('https://victim.com');. victim.comembeds a third-party iframe (idp.com), which callsdocument.requestStorageAccess(). The request is blocked, showing the SAA indicator in the omnibox.- The user clicks the SAA indicator. The bubble opens, asking if
idp.comcan access data onvictim.com. - The user toggles the permission to “Allowed” but does not immediately close the bubble.
- Before the bubble closes, the attacker’s page uses its window reference to navigate the popup:
w.location = 'https://evil.com/';. - The navigation commits.
PrimaryPageChangeddetects the navigation and initiates the widget closure. - During the widget’s
WindowClosingphase,CommitChanges()is executed. CommitChanges()readsweb_contents()->GetURL(), which is nowhttps://evil.com/.- A 30-day
ALLOWgrant is written to theHostContentSettingsMapfor the pair(idp.com, evil.com)instead of(idp.com, victim.com). - The attacker can now silently embed
idp.comonevil.comand 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.