CVE-2026-113335
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/android/extensions/extensions_toolbar_android.cc |
modified | |
ifchrome/browser/ui/extensions/extensions_toolbar_view_model.cc |
modified | |
ifchrome/browser/ui/views/extensions/extensions_request_access_button.cc |
modified |
Files Changed
chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.ccchrome/browser/ui/extensions/extensions_toolbar_view_model.ccchrome/browser/ui/android/extensions/java/src/org/chromium/chrome/browser/ui/extensions/ExtensionsToolbarBridge.javachrome/browser/ui/views/extensions/extensions_toolbar_desktop_interactive_uitest.ccchrome/browser/ui/android/extensions/extensions_toolbar_android.hchrome/browser/ui/views/extensions/extensions_request_access_button.ccchrome/browser/ui/android/extensions/extensions_toolbar_android.ccchrome/browser/ui/views/extensions/extensions_request_access_button.hchrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediatorTest.javachrome/browser/ui/extensions/extensions_toolbar_view_model.hchrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediator.java
Patch
From 786b88034eb1d1f06d4d680ae13fc7aa0ded8008 Mon Sep 17 00:00:00 2001 From: Eva Su <[email protected]> Date: Thu, 17 Sep 2026 15:38:07 -0700 Subject: [PATCH] Track and validate expected origin in ExtensionsToolbarViewModel Currently, ExtensionsToolbarViewModel::GrantSiteAccess() queries the committed origin directly from WebContents at click time. If the active tab navigates before the click action is processed, permissions could be applied to the newly committed origin instead of the origin displayed when the button was created. This CL updates RequestAccessButtonParams to store the origin for which the button parameters were generated. ExtensionsToolbarViewModel now accepts and forwards the expected origin to SitePermissionsHelper for origin-consistency verification, and toolbar UI handlers validate that the current main frame origin matches the expected origin before updating site access. Fixed: 518055882 Change-Id: Ice8634bc1c5038970a2d48e2413b2e68a779cadc Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8413090 Reviewed-by: Tim <[email protected]> Commit-Queue: Eva Su <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700196} --- diff --git a/chrome/browser/ui/android/extensions/extensions_toolbar_android.cc b/chrome/browser/ui/android/extensions/extensions_toolbar_android.cc index 875e30c..ad245e1 100644 --- a/chrome/browser/ui/android/extensions/extensions_toolbar_android.cc +++ b/chrome/browser/ui/android/extensions/extensions_toolbar_android.cc @@ -5,6 +5,7 @@ #include "chrome/browser/ui/android/extensions/extensions_toolbar_android.h" #include <cstdint> +#include <utility> #include "base/android/jni_array.h" #include "base/android/jni_string.h" @@ -111,16 +112,21 @@ ExtensionsToolbarAndroid::GetRequestAccessButtonParams( JNIEnv* env, content::WebContents* web_contents) { - ExtensionsToolbarViewModel::RequestAccessButtonParams params; if (ToolbarActionsModel::CanShowActionsInToolbar(*browser_)) { - params = toolbar_view_model_->GetRequestAccessButtonParams(web_contents); + request_access_button_params_ = + toolbar_view_model_->GetRequestAccessButtonParams(web_contents); + } else { + request_access_button_params_ = + ExtensionsToolbarViewModel::RequestAccessButtonParams(); } - return Java_RequestAccessButtonParams_Constructor(env, params.extension_ids, - params.tooltip_text); + return Java_RequestAccessButtonParams_Constructor( + env, request_access_button_params_.extension_ids, + request_access_button_params_.tooltip_text); } void ExtensionsToolbarAndroid::OnRequestAccessButtonParamsChanged( content::WebContents* web_contents) { + request_access_button_params_ = {}; Java_ExtensionsToolbarBridge_onRequestAccessButtonParamsChanged( AttachCurrentThread(), java_object_); } @@ -197,6 +203,8 @@ void ExtensionsToolbarAndroid::OnActiveWebContentsChanged( bool /*is_same_document*/, content::WebContents* web_contents) { + request_access_button_params_ = + ExtensionsToolbarViewModel::RequestAccessButtonParams(); Java_ExtensionsToolbarBridge_onActiveWebContentsChanged( AttachCurrentThread(), java_object_, web_contents ? web_contents->GetJavaWebContents() : nullptr); @@ -376,13 +384,31 @@ return toolbar_view_model_->IsActionDraggable(action_id); } -void ExtensionsToolbarAndroid::OnRequestAccessButtonClicked( +bool ExtensionsToolbarAndroid::OnRequestAccessButtonClicked( JNIEnv* env, content::WebContents* web_contents) { - ExtensionsToolbarViewModel::RequestAccessButtonParams params = - toolbar_view_model_->GetRequestAccessButtonParams(web_contents); + if (!web_contents) { + return false; + } - toolbar_view_model_->GrantSiteAccess(web_contents, params.extension_ids); + ExtensionsToolbarViewModel::RequestAccessButtonParams params = + std::exchange(request_access_button_params_, {}); + + // Verify that the origin displayed when the action was initiated matches the + // current origin of the WebContents. + if (params.origin.opaque() || + !params.origin.IsSameOriginWith( + web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin())) { + return false; + } + + if (params.extension_ids.empty()) { + return false; + } + + toolbar_view_model_->GrantSiteAccess(web_contents, params.extension_ids, + params.origin); + return true; } void ExtensionsToolbarAndroid::ExecuteUserAction( diff --git a/chrome/browser/ui/android/extensions/extensions_toolbar_android.h b/chrome/browser/ui/android/extensions/extensions_toolbar_android.h index 7e39889..a549703 100644 --- a/chrome/browser/ui/android/extensions/extensions_toolbar_android.h +++ b/chrome/browser/ui/android/extensions/extensions_toolbar_android.h @@ -95,7 +95,7 @@ const ToolbarActionsModel::ActionId& action_id); void ExecuteUserAction(const ToolbarActionsModel::ActionId& action_id, ToolbarActionViewModel::InvocationSource source); - void OnRequestAccessButtonClicked(JNIEnv* env, + bool OnRequestAccessButtonClicked(JNIEnv* env, content::WebContents* web_contents); void MovePinnedAction(const ToolbarActionsModel::ActionId& action_id, int target_index); @@ -112,6 +112,10 @@ // The view model for this container. std::unique_ptr<ExtensionsToolbarViewModel> toolbar_view_model_; + // Holds the current parameters for the request access button. + ExtensionsToolbarViewModel::RequestAccessButtonParams + request_access_button_params_; + // Registers ExtensionsToolbarViewModel as the ExtensionsContainer for the // browser window. ui::ScopedUnownedUserData<ExtensionsContainer> diff --git a/chrome/browser/ui/android/extensions/java/src/org/chromium/chrome/browser/ui/extensions/ExtensionsToolbarBridge.java b/chrome/browser/ui/android/extensions/java/src/org/chromium/chrome/browser/ui/extensions/ExtensionsToolbarBridge.java index fb7b8bc0..30bf2a1 100644 --- a/chrome/browser/ui/android/extensions/java/src/org/chromium/chrome/browser/ui/extensions/ExtensionsToolbarBridge.java +++ b/chrome/browser/ui/android/extensions/java/src/org/chromium/chrome/browser/ui/extensions/ExtensionsToolbarBridge.java @@ -122,9 +122,9 @@ .movePinnedAction(mNativeExtensionsToolbarAndroid, actionId, targetIndex); } - public void onRequestAccessButtonClicked(WebContents webContents) { + public boolean onRequestAccessButtonClicked(WebContents webContents) { assert mNativeExtensionsToolbarAndroid != 0; - ExtensionsToolbarBridgeJni.get() + return ExtensionsToolbarBridgeJni.get() .onRequestAccessButtonClicked(mNativeExtensionsToolbarAndroid, webContents); } @@ -382,7 +382,7 @@ @JniType("std::string") String actionId, int targetIndex); - void onRequestAccessButtonClicked( + boolean onRequestAccessButtonClicked( long nativeExtensionsToolbarAndroid, @JniType("content::WebContents*") WebContents webContents); diff --git a/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediator.java b/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediator.java index 936366a..53ff5140 100644 --- a/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediator.java +++ b/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediator.java @@ -253,6 +253,11 @@ } private void handleAccessGranted(WebContents webContents) { + if (!mExtensionsToolbarBridge.onRequestAccessButtonClicked(webContents)) { + refreshRequestAccessButton(); + return; + } + mIsShowingAllowedText = true; mWebContentsShowingAllowedText = webContents; mModel.set( @@ -265,8 +270,6 @@ ExtensionsToolbarProperties.REQUEST_ACCESS_BUTTON_CLICK_LISTENER, null); // Disable further clicks - mExtensionsToolbarBridge.onRequestAccessButtonClicked(webContents); - // We post a delayed task to re-evaluate the button state so it naturally disappears // after the user has a moment to see the "Allowed" text. // We store the Runnable and cancel it in destroy() to prevent crashes if the mediator diff --git a/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediatorTest.java b/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediatorTest.java index 0b88f15..7fccdc1 100644 --- a/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediatorTest.java +++ b/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/extensions/ExtensionAccessControlButtonMediatorTest.java @@ -111,6 +111,7 @@ when(mExtensionsToolbarBridge.getRequestAccessButtonParams(any())) .thenReturn(new RequestAccessButtonParams(new String[0], "")); + when(mExtensionsToolbarBridge.onRequestAccessButtonClicked(any())).thenReturn(true); mModel = new PropertyModel.Builder( @@ -238,6 +239,32 @@ } @Test + public void testRequestAccessButton_NativeCallFailsDoesNotShowAllowed() { + ExtensionsToolbarBridge.Observer observer = mToolbarObserverCaptor.getValue(); + + RequestAccessButtonParams paramsWithRequests = + new RequestAccessButtonParams(new String[] {"a"}, "Tooltip"); + when(mExtensionsToolbarBridge.getRequestAccessButtonParams(mWebContents)) + .thenReturn(paramsWithRequests); + when(mExtensionsToolbarBridge.onRequestAccessButtonClicked(mWebContents)).thenReturn(false); + + observer.onActiveWebContentsChanged(mWebContents); + assertTrue(mModel.get(ExtensionsToolbarProperties.IS_REQUEST_ACCESS_BUTTON_VISIBLE)); + assertEquals( + 1, mModel.get(ExtensionsToolbarProperties.REQUEST_ACCESS_BUTTON_EXTENSION_COUNT)); + + // User clicks the button, but native call returns false (e.g. origin mismatch). + OnClickListener listener = + mModel.get(ExtensionsToolbarProperties.REQUEST_ACCESS_BUTTON_CLICK_LISTENER); + listener.onClick(null); + + // Button should not enter the "Allowed" state (-1). + assertEquals( + 1, mModel.get(ExtensionsToolbarProperties.REQUEST_ACCESS_BUTTON_EXTENSION_COUNT)); + verify(mExtensionsToolbarBridge).onRequestAccessButtonClicked(mWebContents); + } + + @Test public void testRequestAccessButtonVisibility_CompactWindow() { ExtensionsToolbarBridge.Observer observer = mToolbarObserverCaptor.getValue(); when(mIsWindowCompactSupplier.get()).thenReturn(true); diff --git a/chrome/browser/ui/extensions/extensions_toolbar_view_model.cc b/chrome/browser/ui/extensions/extensions_toolbar_view_model.cc index 0997e680..7788832d 100644 --- a/chrome/browser/ui/extensions/extensions_toolbar_view_model.cc +++ b/chrome/browser/ui/extensions/extensions_toolbar_view_model.cc @@ -282,8 +282,9 @@ void ExtensionsToolbarViewModel::GrantSiteAccess( content::WebContents* web_contents, - const std::vector<extensions::ExtensionId>& extension_ids) { - if (!actions_model_) { + const std::vector<extensions::ExtensionId>& extension_ids, + const url::Origin& expected_origin) { + if (!actions_model_ || !web_contents) { return; } @@ -300,8 +301,7 @@ extensions::SitePermissionsHelper(profile).UpdateSiteAccess( extensions_to_run, web_contents, - extensions::PermissionsManager::UserSiteAccess::kOnSite, - web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin()); + extensions::PermissionsManager::UserSiteAccess::kOnSite, expected_origin); } // Extensions are included in the request access button only when: @@ -352,6 +352,8 @@ return params; } + params.origin = origin; + std::vector<std::u16string> tooltip_parts; tooltip_parts.push_back(l10n_util::GetStringFUTF16( IDS_EXTENSIONS_REQUEST_ACCESS_BUTTON_TOOLTIP_MULTIPLE_EXTENSIONS, diff --git a/chrome/browser/ui/extensions/extensions_toolbar_view_model.h b/chrome/browser/ui/extensions/extensions_toolbar_view_model.h index 0a2a0cb..003c27b 100644 --- a/chrome/browser/ui/extensions/extensions_toolbar_view_model.h +++ b/chrome/browser/ui/extensions/extensions_toolbar_view_model.h @@ -17,6 +17,7 @@ #include "extensions/browser/permissions_manager.h" #include "extensions/buildflags/buildflags.h" #include "ui/gfx/vector_icon_types.h" +#include "url/origin.h" static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE)); @@ -120,6 +121,7 @@ std::vector<extensions::ExtensionId> extension_ids; std::u16string tooltip_text; + url::Origin origin; }; ExtensionsToolbarViewModel(Delegate* delegate, @@ -186,10 +188,12 @@ void ExecuteUserAction(const ToolbarActionsModel::ActionId& action_id, ToolbarActionViewModel::InvocationSource source); - // Grants site access to the given `extension_ids` for the `web_contents`. + // Grants site access to the given `extension_ids` for the `web_contents` on + // `expected_origin`. void GrantSiteAccess( content::WebContents* web_contents, - const std::vector<extensions::ExtensionId>& extension_ids); + const std::vector<extensions::ExtensionId>& extension_ids, + const url::Origin& expected_origin); // Returns RequestAccessButtonParams which contain information to be used in // the button's tooltip. diff --git a/chrome/browser/ui/views/extensions/extensions_request_access_button.cc b/chrome/browser/ui/views/extensions/extensions_request_access_button.cc index 8035f998..912e99a2 100644 --- a/chrome/browser/ui/views/extensions/extensions_request_access_button.cc +++ b/chrome/browser/ui/views/extensions/extensions_request_access_button.cc @@ -64,6 +64,7 @@ request_access_button_params) { CHECK(!IsShowingConfirmation()); extension_ids_ = request_access_button_params.extension_ids; + origin_ = request_access_button_params.origin; SetVisible(!request_access_button_params.extension_ids.empty()); SetTooltipText(request_access_button_params.tooltip_text); @@ -213,21 +214,31 @@ feature_engagement::kIPHExtensionsRequestAccessButtonFeature, FeaturePromoFeatureUsedAction::kClosePromoIfPresent); content::WebContents* web_contents = GetActiveWebContents(); + if (!web_contents) { + return; + } extensions::ExtensionActionRunner* action_runner = extensions::ExtensionActionRunner::GetForWebContents(web_contents); if (!action_runner) { return; } + // Verify that the origin displayed when the action was initiated matches the + // current origin of the WebContents. + if (!origin_.IsSameOriginWith( + web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin())) { + return; + } + // Make sure we set this before granting tab permissions, since that will // trigger an update to the request access button for each extension that is // granted access. - confirmation_origin_ = - web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin(); + confirmation_origin_ = origin_; // Always grant access to this site to all extensions. DCHECK_GT(extension_ids_.size(), 0u); - extensions_toolbar_view_model_->GrantSiteAccess(web_contents, extension_ids_); + extensions_toolbar_view_model_->GrantSiteAccess(web_contents, extension_ids_, + origin_); // Show confirmation message, and disable the button, for a specific duration. std::optional<SkColor> color; diff --git a/chrome/browser/ui/views/extensions/extensions_request_access_button.h b/chrome/browser/ui/views/extensions/extensions_request_access_button.h index 263984c..51aaec8 100644 --- a/chrome/browser/ui/views/extensions/extensions_request_access_button.h +++ b/chrome/browser/ui/views/extensions/extensions_request_access_button.h @@ -104,6 +104,9 @@ // Extensions included in the request access button. std::vector<extensions::ExtensionId> extension_ids_; + // The origin for which the button is displaying access requests. + url::Origin origin_; + // The origin for which the button is displaying a confirmation message, if // any. std::optional<url::Origin> confirmation_origin_; diff --git a/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc b/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc index c6d6f1e0..f9482b96 100644 --- a/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc +++ b/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc @@ -349,6 +349,7 @@ toolbar_model()->GetRequestAccessButtonParams(GetActiveWebContents()); EXPECT_TRUE(params.extension_ids.empty()); EXPECT_TRUE(params.tooltip_text.empty()); + EXPECT_TRUE(params.origin.opaque()); } IN_PROC_BROWSER_TEST_F(ExtensionsToolbarViewModelBrowserTest, @@ -370,6 +371,8 @@ EXPECT_THAT(params.extension_ids, testing::ElementsAre(extension_a->id())); EXPECT_NE(params.tooltip_text.find(u"Extension A"), std::u16string::npos); EXPECT_NE(params.tooltip_text.find(u"example.com"), std::u16string::npos); + EXPECT_EQ(params.origin, + web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin()); // Add a second request const std::string extension_b_id = @@ -383,6 +386,8 @@ extension_a->id(), extension_b->id())); EXPECT_NE(params.tooltip_text.find(u"Extension A"), std::u16string::npos); EXPECT_NE(params.tooltip_text.find(u"Extension B"), std::u16string::npos); + EXPECT_EQ(params.origin, + web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin()); // Remove the request for extension A permissions_manager->RemoveHostAccessRequest(tab_id, extension_a->id()); @@ -390,12 +395,15 @@ EXPECT_THAT(params.extension_ids, testing::ElementsAre(extension_b->id())); EXPECT_EQ(params.tooltip_text.find(u"Extension A"), std::u16string::npos); EXPECT_NE(params.tooltip_text.find(u"Extension B"), std::u16string::npos); + EXPECT_EQ(params.origin, + web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin()); // Remove the second request to return to the initial state permissions_manager->RemoveHostAccessRequest(tab_id, extension_b->id()); params = toolbar_model()->GetRequestAccessButtonParams(web_contents); EXPECT_TRUE(params.extension_ids.empty()); EXPECT_TRUE(params.tooltip_text.empty()); ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc b/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc
index c6d6f1e0..f9482b96 100644
--- a/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc
+++ b/chrome/browser/ui/extensions/extensions_toolbar_view_model_browsertest.cc
@@ -349,6 +349,7 @@
toolbar_model()->GetRequestAccessButtonParams(GetActiveWebContents());
EXPECT_TRUE(params.extension_ids.empty());
EXPECT_TRUE(params.tooltip_text.empty());
+ EXPECT_TRUE(params.origin.opaque());
}
IN_PROC_BROWSER_TEST_F(ExtensionsToolbarViewModelBrowserTest,
@@ -370,6 +371,8 @@
EXPECT_THAT(params.extension_ids, testing::ElementsAre(extension_a->id()));
EXPECT_NE(params.tooltip_text.find(u"Extension A"), std::u16string::npos);
EXPECT_NE(params.tooltip_text.find(u"example.com"), std::u16string::npos);
+ EXPECT_EQ(params.origin,
+ web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
// Add a second request
const std::string extension_b_id =
@@ -383,6 +386,8 @@
extension_a->id(), extension_b->id()));
EXPECT_NE(params.tooltip_text.find(u"Extension A"), std::u16string::npos);
EXPECT_NE(params.tooltip_text.find(u"Extension B"), std::u16string::npos);
+ EXPECT_EQ(params.origin,
+ web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
// Remove the request for extension A
permissions_manager->RemoveHostAccessRequest(tab_id, extension_a->id());
@@ -390,12 +395,15 @@
EXPECT_THAT(params.extension_ids, testing::ElementsAre(extension_b->id()));
EXPECT_EQ(params.tooltip_text.find(u"Extension A"), std::u16string::npos);
EXPECT_NE(params.tooltip_text.find(u"Extension B"), std::u16string::npos);
+ EXPECT_EQ(params.origin,
+ web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
// Remove the second request to return to the initial state
permissions_manager->RemoveHostAccessRequest(tab_id, extension_b->id());
params = toolbar_model()->GetRequestAccessButtonParams(web_contents);
EXPECT_TRUE(params.extension_ids.empty());
EXPECT_TRUE(params.tooltip_text.empty());
+ EXPECT_TRUE(params.origin.opaque());
}
// Tests that GetRequestAccessButtonParams returns empty when an extension has
@@ -410,6 +418,62 @@
toolbar_model()->GetRequestAccessButtonParams(GetActiveWebContents());
EXPECT_TRUE(params.extension_ids.empty());
EXPECT_TRUE(params.tooltip_text.empty());
+ EXPECT_TRUE(params.origin.opaque());
+}
+
+// Tests that GrantSiteAccess succeeds when the expected origin matches the
+// current web contents origin.
+IN_PROC_BROWSER_TEST_F(ExtensionsToolbarViewModelBrowserTest,
+ GrantSiteAccess_SucceedsIfOriginMatches) {
+ auto extension = AddExtension("Test Extension", {}, {"*://example.com/*"},
+ /*withhold_permissions=*/true);
+
+ NavigateTo("example.com");
+ auto* web_contents = GetActiveWebContents();
+ auto origin = web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin();
+
+ auto* permissions_manager = extensions::PermissionsManager::Get(profile());
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(
+ *extension, web_contents->GetLastCommittedURL()),
+ extensions::PermissionsManager::UserSiteAccess::kOnClick);
+
+ toolbar_model()->GrantSiteAccess(web_contents, {extension->id()}, origin);
+
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(
+ *extension, web_contents->GetLastCommittedURL()),
+ extensions::PermissionsManager::UserSiteAccess::kOnSite);
+}
+
+// Tests that GrantSiteAccess fails to grant site access if the expected origin
+// does not match the current web contents origin.
+IN_PROC_BROWSER_TEST_F(ExtensionsToolbarViewModelBrowserTest,
+ GrantSiteAccess_FailsIfOriginChanges) {
+ auto extension = AddExtension("Test Extension", {}, {"*://example.com/*"},
+ /*withhold_permissions=*/true);
+
+ NavigateTo("example.com");
+ auto* web_contents = GetActiveWebContents();
+ auto origin_a = web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin();
+
+ auto* permissions_manager = extensions::PermissionsManager::Get(profile());
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(
+ *extension, web_contents->GetLastCommittedURL()),
+ extensions::PermissionsManager::UserSiteAccess::kOnClick);
+
+ // Navigate to a different site before the action is executed.
+ NavigateTo("other.com");
+
+ // Attempt to grant site access using the origin of the previous site.
+ toolbar_model()->GrantSiteAccess(web_contents, {extension->id()}, origin_a);
+
+ // Site access should NOT be granted for either the previous site or the new
+ // site.
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(*extension,
+ GURL("http://example.com/")),
+ extensions::PermissionsManager::UserSiteAccess::kOnClick);
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(*extension,
+ GURL("http://other.com/")),
+ extensions::PermissionsManager::UserSiteAccess::kOnClick);
}
// Tests that a pinned action is draggable.
diff --git a/chrome/browser/ui/views/extensions/extensions_toolbar_desktop_interactive_uitest.cc b/chrome/browser/ui/views/extensions/extensions_toolbar_desktop_interactive_uitest.cc
index 362e1b8..fa3134e 100644
--- a/chrome/browser/ui/views/extensions/extensions_toolbar_desktop_interactive_uitest.cc
+++ b/chrome/browser/ui/views/extensions/extensions_toolbar_desktop_interactive_uitest.cc
@@ -1255,6 +1255,81 @@
UserSiteAccess::kOnAllSites);
}
+// Tests that the request access button does not grant site access if the
+// button's expected origin does not match the tab's current committed origin.
+IN_PROC_BROWSER_TEST_F(ExtensionsToolbarDesktopFeatureUITest,
+ RequestAccessButton_DoesNotGrantIfOriginChanges) {
+ auto extension =
+ InstallExtensionWithHostPermissions("Test Extension", "<all_urls>");
+ extensions::ScriptingPermissionsModifier(profile(), extension)
+ .SetWithholdHostPermissions(true);
+
+ auto* permissions_manager = extensions::PermissionsManager::Get(profile());
+ auto* web_contents = browser()->tab_strip_model()->GetActiveWebContents();
+
+ const GURL site_a_url =
+ embedded_test_server()->GetURL("example.com", "/title1.html");
+ NavigateToUrl(site_a_url);
+ AddHostAccessRequest(*extension, web_contents);
+ WaitForAnimation();
+
+ EXPECT_TRUE(request_access_button()->GetVisible());
+ EXPECT_THAT(request_access_button()->GetExtensionIdsForTesting(),
+ testing::ElementsAre(extension->id()));
+ const auto site_a_params = GetExtensionsToolbarDesktop()
+ ->GetToolbarViewModel()
+ ->GetRequestAccessButtonParams(web_contents);
+ EXPECT_EQ(site_a_params.origin, url::Origin::Create(site_a_url));
+
+ const GURL site_b_url =
+ embedded_test_server()->GetURL("other.com", "/title1.html");
+ NavigateToUrl(site_b_url);
+ WaitForAnimation();
+
+ // Navigation to site B reset the request access button. Manually update the
+ // button with site A's info to simulate the race condition where the button
+ // was displaying requests for site A when the click action was dispatched.
+ request_access_button()->Update(site_a_params);
+
+ EXPECT_TRUE(request_access_button()->GetVisible());
+ EXPECT_THAT(request_access_button()->GetExtensionIdsForTesting(),
+ testing::ElementsAre(extension->id()));
+
+ // Don't show the timed confirmation overlay.
+ request_access_button()->remove_confirmation_for_testing(true);
+
+ EXPECT_FALSE(
+ permissions_manager->HasGrantedHostPermission(*extension, site_a_url));
+ EXPECT_FALSE(
+ permissions_manager->HasGrantedHostPermission(*extension, site_b_url));
+
+ // User action is processed when the button has site A details, but the tab
+ // has already navigated to site B.
+ ClickButton(request_access_button());
+ WaitForAnimation();
+
+ // Verify site access was not granted to either site and no confirmation was
+ // shown.
+ EXPECT_FALSE(request_access_button()->IsShowingConfirmation());
+ EXPECT_FALSE(
+ permissions_manager->HasGrantedHostPermission(*extension, site_b_url));
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(*extension, site_b_url),
+ UserSiteAccess::kOnClick);
+ EXPECT_FALSE(
+ permissions_manager->HasGrantedHostPermission(*extension, site_a_url));
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(*extension, site_a_url),
+ UserSiteAccess::kOnClick);
+
+ // Also verify that direct view model calls with a mismatched expected origin
+ // do not grant access.
+ GetExtensionsToolbarDesktop()->GetToolbarViewModel()->GrantSiteAccess(
+ web_contents, {extension->id()}, url::Origin::Create(site_a_url));
+ EXPECT_FALSE(
+ permissions_manager->HasGrantedHostPermission(*extension, site_b_url));
+ EXPECT_EQ(permissions_manager->GetUserSiteAccess(*extension, site_b_url),
+ UserSiteAccess::kOnClick);
+}
+
// Tests that when the user clicks on the request access button and immediately
// navigates to a different site, the confirmation text is collapsed and the
// button displays the extensions requesting access to the new site (if any).
Original Bug Report
TOCTOU bypass in Extensions request-access chip allows unauthorized permission grant
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the extensions request-access toolbar chip UI on Desktop and Android. Because the toolbar model queries and passes the live, click-time committed origin of the web contents as the expected origin to the permissions helper, the security check is rendered a tautology. A malicious extension could coordinate with an attacker-controlled site to leverage a timed navigation race during a user’s click to obtain persistent site permissions for an arbitrary victim origin.
Affected files:
chrome/browser/ui/extensions/extensions_toolbar_view_model.ccchrome/browser/ui/views/extensions/extensions_request_access_button.ccchrome/browser/ui/android/extensions/extensions_toolbar_android.ccchrome/browser/ui/extensions/extensions_toolbar_view_model.h
Estimated timestamp from git blame: 2026-05-28
Summary
A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the extensions request-access toolbar button on both Desktop and Android. This could potentially allow a restricted extension to bypass origin-verification checks and gain persistent site permissions to a victim origin.
Technical Details
The SitePermissionsHelper::UpdateSiteAccess method is responsible for updating site access permissions. To prevent TOCTOU navigation races, it accepts an expected_origin parameter and verifies that the current main frame’s origin has not changed since the action was initiated:
// extensions/browser/permissions/site_permissions_helper.cc
void SitePermissionsHelper::UpdateSiteAccess(
const std::vector<const Extension*>& extensions,
content::WebContents* web_contents,
PermissionsManager::UserSiteAccess new_access,
const url::Origin& expected_origin) {
url::Origin actual_origin =
web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin();
...
if (!expected_origin.IsSameOriginWith(actual_origin)) {
return; // Safety guard: abort if navigation occurred
}
...
}
However, when granting site access via the toolbar’s request-access button on Desktop or Android, ExtensionsToolbarViewModel::GrantSiteAccess retrieves the live committed origin from the web contents at the exact moment the click is processed, and uses that as the expected_origin:
// chrome/browser/ui/extensions/extensions_toolbar_view_model.cc
void ExtensionsToolbarViewModel::GrantSiteAccess(
content::WebContents* web_contents,
const std::vector<extensions::ExtensionId>& extension_ids) {
...
extensions::SitePermissionsHelper(profile).UpdateSiteAccess(
extensions_to_run, web_contents,
extensions::PermissionsManager::UserSiteAccess::kOnSite,
web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin()); // <-- Live click-time origin!
}
Because expected_origin is fetched live inside the click handler on the UI thread, the comparison expected_origin.IsSameOriginWith(actual_origin) becomes a tautology (actual_origin == actual_origin). Consequently, if a page navigation commits right before the click event is processed, the safety guard evaluates to true on the newly navigated (victim) origin.
This contrasts with other surfaces like ExtensionContextMenuModel (in chrome/browser/extensions/extension_context_menu_model.cc), which correctly capture the origin when the menu/UI is rendered (origin_) and supply that stored origin when executing the action.
Potential Attack Scenario
Please note that these are suggested/potential steps as our tooling agent does not currently have the capability to run code to produce a working proof of concept.
- A user-installed extension with withheld host permissions (configured for “On click” access) requests host access on an attacker-controlled page
https://attacker.exampleviachrome.permissions.addHostAccessRequest. - The browser displays the request-access chip labeled “Allow 1”, attributing the request to
https://attacker.example. - As the user hovers over the chip and initiates a click, the attacker-controlled page programmatically navigates the tab to
https://victim.example. - On navigation commit, the previous host access request is cleared. However, the extension’s navigation event listener (e.g.,
onCommitted) immediately callsaddHostAccessRequestagain, registering the request on the newly loadedhttps://victim.exampleorigin before the click registers on the UI thread. - When the user’s click completes,
ExtensionsRequestAccessButton::OnButtonPressedorExtensionsToolbarAndroid::OnRequestAccessButtonClickedis triggered, callingGrantSiteAccess. - Since
GrantSiteAccessretrieves the live committed origin (victim.example) and passes it asexpected_origin, the guard inSitePermissionsHelper::UpdateSiteAccessevaluates totruebecause they are both the victim origin. - The extension is granted persistent, unrestricted site permission (
UserSiteAccess::kOnSite) tohttps://victim.example, bypassing the security check.
Suggested Fix
To remediate this potential issue, modify ExtensionsToolbarViewModel::RequestAccessButtonParams or the button UI classes to capture and store the origin at the time the request is rendered, and pass this stored origin as the expected_origin to GrantSiteAccess and UpdateSiteAccess instead of retrieving the live committed origin at click time.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.