CVE-2026-113112
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/omnibox/omnibox_api.cc |
modified | |
ifchrome/browser/extensions/extension_action_runner.cc |
modified | |
forchrome/browser/extensions/extension_action_runner.cc |
modified | |
ifchrome/browser/extensions/extension_keybinding_registry.cc |
modified |
Files Changed
chrome/browser/extensions/api/context_menus/extension_context_menu_browsertest.ccchrome/browser/ui/extensions/extensions_menu_view_model.ccchrome/browser/extensions/api/omnibox/omnibox_api.ccchrome/browser/ui/extensions/extensions_menu_view_model_browsertest.ccchrome/browser/extensions/permissions/host_access_requests_helper_unittest.ccchrome/browser/extensions/api/declarative_net_request/declarative_net_request_browsertest.ccchrome/browser/extensions/api/page_capture/page_capture_apitest.ccchrome/browser/extensions/extension_action_runner.hchrome/browser/extensions/extension_action_runner.ccchrome/browser/extensions/permissions/active_tab_apitest.ccchrome/browser/ui/views/extensions/extensions_menu_main_page_view_browsertest.ccchrome/browser/extensions/api/tabs/tabs_apitest.ccextensions/browser/permissions/active_tab_permission_granter.hchrome/browser/extensions/permissions/active_tab_unittest.ccchrome/browser/extensions/extension_action_runner_unittest.ccchrome/browser/extensions/permissions/site_permissions_helper_browsertest.ccextensions/browser/network_permissions_updater.ccchrome/browser/extensions/menu_manager.hchrome/browser/extensions/extension_keybinding_registry.ccextensions/browser/network_permissions_updater.hchrome/browser/extensions/menu_manager.ccchrome/browser/extensions/api/permissions/permissions_api_browsertest.ccchrome/browser/extensions/script_injection_tracker_browsertest.ccchrome/browser/extensions/orb_and_cors_extension_browsertest.ccchrome/browser/extensions/api/tab_capture/tab_capture_apitest.ccextensions/browser/permissions/active_tab_permission_granter.cc
Patch
From 759e022a15fa81f865a60b9ad2af327280308710 Mon Sep 17 00:00:00 2001
From: Lukasz Anforowicz <lukasza@chromium.org>
Date: Tue, 27 Oct 2026 15:01:18 -0700
Subject: [PATCH] Fix races between activeTab grants and extension events.
After https://crrev.com/c/7728286, `GrantIfRequested` notified renderers
only after the Network Service ACKed the `OriginAccessList` update, but
callers dispatched events like `action.onClicked` right away. This
caused two races:
1. `chrome.scripting.executeScript` from `onClicked` could reach the
tab's renderer before `UpdateTabSpecificPermissions`, and fail.
2. `fetch` from `onClicked` could reach the Network Service before the
`OriginAccessList` update, and be blocked by CORS or ORB.
After this CL:
* `GrantIfRequested` notifies renderers (and `ScriptInjectionTracker`)
synchronously. This keeps the renderer IPC ordered before later
IPCs (e.g. `ExecuteCode`, or the IPC that revokes the grant).
* `GrantIfRequested` takes an `on_granted` callback that runs after the
Network Service ACK. Callers that dispatch events to the extension
(action, commands, context menus, omnibox) dispatch from this
callback.
* Observers are still notified after the Network Service ACK.
* A repeated grant doesn't send another `OriginAccessList` update. If
the earlier update is still in flight, `on_granted` waits for it.
* Context menu events are not dispatched if the clicked frame has
navigated (or entered the back/forward cache) in the meantime.
The new `NetworkPermissionsUpdater::HoldNextUpdateForTesting` lets
tests delay the Network Service update, so that the new tests can
reproduce the races deterministically. The tests also cover revoking
a grant before the Network Service update (https://crbug.com/513512147)
and `fetch` from an action popup.
Fixed: 570354836
Change-Id: I35453c3b0262aa9fb1049059e96205b01dea2c01
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8562008
Reviewed-by: Eva Su <evasu@chromium.org>
Commit-Queue: Łukasz Anforowicz <lukasza@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1728563}
---
diff --git a/chrome/browser/extensions/api/omnibox/omnibox_api.cc b/chrome/browser/extensions/api/omnibox/omnibox_api.cc
index 9eb15ad7..4cc6661 100644
--- a/chrome/browser/extensions/api/omnibox/omnibox_api.cc
+++ b/chrome/browser/extensions/api/omnibox/omnibox_api.cc
@@ -15,6 +15,7 @@
#include "base/functional/bind.h"
#include "base/lazy_instance.h"
+#include "base/memory/weak_ptr.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/values.h"
@@ -160,8 +161,6 @@
ExtensionRegistry::Get(profile)->enabled_extensions().GetByID(
extension_id);
CHECK(extension);
- extensions::ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension);
auto args = base::MakeRefCounted<EventArgs>();
args->data.Append(input);
@@ -177,8 +176,23 @@
omnibox::OnInputEntered::kEventName,
std::move(args), profile);
event->user_gesture = EventRouter::UserGestureState::kEnabled;
- EventRouter::Get(profile)
- ->DispatchEventToExtension(extension_id, std::move(event));
+
+ // Dispatch the event only after the activeTab grant takes effect, so that the
+ // extension can use the grant right away.
+ extensions::ActiveTabPermissionGranter::FromWebContents(web_contents)
+ ->GrantIfRequested(
+ extension,
+ base::BindOnce(
+ [](base::WeakPtr<content::WebContents> web_contents,
+ const ExtensionId& extension_id,
+ std::unique_ptr<Event> event) {
+ if (!web_contents) {
+ return;
+ }
+ EventRouter::Get(web_contents->GetBrowserContext())
+ ->DispatchEventToExtension(extension_id, std::move(event));
+ },
+ web_contents->GetWeakPtr(), extension_id, std::move(event)));
OmniboxInputWatcherFactory::GetForBrowserContext(profile)
->NotifyInputEntered();
diff --git a/chrome/browser/extensions/extension_action_runner.cc b/chrome/browser/extensions/extension_action_runner.cc
index 4773b5d4..944ecef 100644
--- a/chrome/browser/extensions/extension_action_runner.cc
+++ b/chrome/browser/extensions/extension_action_runner.cc
@@ -10,6 +10,7 @@
#include <vector>
#include "base/auto_reset.h"
+#include "base/barrier_closure.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/location.h"
@@ -102,7 +103,7 @@
// granting active tab will have run the extension. Don't execute further
// since clicking should run blocked actions *or* the normal extension
// action, not both.
- GrantTabPermissions({extension});
+ GrantTabPermissions({extension}, base::DoNothing());
return ExtensionAction::ShowAction::kNone;
}
@@ -125,33 +126,58 @@
return ExtensionAction::ShowAction::kToggleSidePanel;
}
- if (grant_tab_permissions) {
- GrantTabPermissions({extension});
- }
-
ExtensionAction* extension_action =
ExtensionActionManager::Get(browser_context_)
->GetExtensionAction(*extension);
DCHECK(extension_action);
- if (!extension_action->GetIsVisible(tab_id)) {
- return ExtensionAction::ShowAction::kNone;
+ ExtensionAction::ShowAction show_action = ExtensionAction::ShowAction::kNone;
+ base::OnceClosure dispatch_on_clicked = base::DoNothing();
+ if (extension_action->GetIsVisible(tab_id)) {
+ if (extension_action->HasPopup(tab_id)) {
+ show_action = ExtensionAction::ShowAction::kShowPopup;
+ } else {
+ dispatch_on_clicked =
+ base::BindOnce(&ExtensionActionRunner::DispatchExtensionActionClicked,
+ weak_factory_.GetWeakPtr(), extension->id());
+ }
}
- if (extension_action->HasPopup(tab_id)) {
- return ExtensionAction::ShowAction::kShowPopup;
+ if (grant_tab_permissions) {
+ // Dispatch `action.onClicked` only after the grant takes effect, so that
+ // the extension can use the grant right away.
+ GrantTabPermissions({extension}, std::move(dispatch_on_clicked));
+ } else {
+ std::move(dispatch_on_clicked).Run();
+ }
+ return show_action;
+}
+
+void ExtensionActionRunner::DispatchExtensionActionClicked(
+ const ExtensionId& extension_id) {
+ const Extension* extension = ExtensionRegistry::Get(browser_context_)
+ ->enabled_extensions()
+ .GetByID(extension_id);
+ if (!extension) {
+ return;
+ }
+ ExtensionAction* extension_action =
+ ExtensionActionManager::Get(browser_context_)
+ ->GetExtensionAction(*extension);
+ if (!extension_action) {
+ return;
}
ExtensionActionDispatcher::Get(browser_context_)
->DispatchExtensionActionClicked(*extension_action, web_contents(),
extension);
- return ExtensionAction::ShowAction::kNone;
}
// TODO(crbug.com/40883928): Consider moving this to SitePermissionsHelper since
// it's more about permissions than running an action.
void ExtensionActionRunner::GrantTabPermissions(
- const std::vector<const Extension*>& extensions) {
+ const std::vector<const Extension*>& extensions,
+ base::OnceClosure on_granted) {
SitePermissionsHelper permissions_helper(browser_context_);
bool refresh_required = std::ranges::any_of(
extensions, [this, &permissions_helper](const Extension* extension) {
@@ -163,11 +189,13 @@
// at the right time) from running until the user refreshes the page.
// Immediately grant permissions to every extension.
auto* granter = ActiveTabPermissionGranter::FromWebContents(web_contents());
+ base::RepeatingClosure barrier =
+ base::BarrierClosure(extensions.size(), std::move(on_granted));
for (auto* extension : extensions) {
if (refresh_required && !granter->IsGranted(extension)) {
extensions_to_ignore_active_tab_granted_.insert(extension->id());
}
- granter->GrantIfRequested(extension);
+ granter->GrantIfRequested(extension, barrier);
}
if (!refresh_required) {
@@ -247,7 +275,7 @@
void ExtensionActionRunner::RunForTesting(const Extension* extension) {
if (WantsToRun(extension)) {
ActiveTabPermissionGranter::FromWebContents(web_contents())
- ->GrantIfRequested(extension);
+ ->GrantIfRequested(extension, base::DoNothing());
}
}
@@ -438,7 +466,7 @@
// The extension may already have active tab at this point, but granting
// it twice is essentially a no-op.
ActiveTabPermissionGranter::FromWebContents(web_contents())
- ->GrantIfRequested(extension);
+ ->GrantIfRequested(extension, base::DoNothing());
RunPendingScriptsForExtension(extension);
web_request_blocked_.erase(extension->id());
diff --git a/chrome/browser/extensions/extension_action_runner.h b/chrome/browser/extensions/extension_action_runner.h
index f24a9a7..48d7cded 100644
--- a/chrome/browser/extensions/extension_action_runner.h
+++ b/chrome/browser/extensions/extension_action_runner.h
@@ -15,6 +15,7 @@
#include "base/functional/callback.h"
#include "base/gtest_prod_util.h"
#include "base/memory/raw_ptr.h"
+#include "base/memory/weak_ptr.h"
#include "base/scoped_observation.h"
#include "chrome/browser/ui/extensions/reload_page_dialog_controller.h"
#include "content/public/browser/global_routing_id.h"
@@ -80,10 +81,14 @@
void RunBlockedActions(const Extension* extension);
// Grants activeTab to `extensions` (this should only be done if this is
- // through a direct user action). The permission will be applied immediately.
+ // through a direct user action). The permission will be applied immediately
+ // in the browser process and in renderers, and `on_granted` will be called
+ // after the permission also takes effect in the network service (see
+ // `ActiveTabPermissionGranter::GrantIfRequested`).
// If any extension needs a page refresh to run, this will show a dialog as
// well.
- void GrantTabPermissions(const std::vector<const Extension*>& extensions);
+ void GrantTabPermissions(const std::vector<const Extension*>& extensions,
+ base::OnceClosure on_granted);
// TODO(crbug.com/424012380): Move to ReloadPageDialogController, as well as
// the on accepted callback.
@@ -209,6 +214,10 @@
// granted or not.
void RunCallbackOnPendingScript(const PendingScriptList& list, bool granted);
+ // Dispatches the `action.onClicked` event to the extension with
+ // `extension_id` (if the extension is still enabled).
+ void DispatchExtensionActionClicked(const ExtensionId& extension_id);
+
// The total number of requests from the renderer on the current page,
// including any that are pending or were immediately granted.
// Right now, used only in tests.
@@ -241,6 +250,8 @@
base::ScopedObservation<ExtensionRegistry, ExtensionRegistryObserver>
extension_registry_observation_{this};
+
+ base::WeakPtrFactory<ExtensionActionRunner> weak_factory_{this};
};
} // namespace extensions
diff --git a/chrome/browser/extensions/extension_keybinding_registry.cc b/chrome/browser/extensions/extension_keybinding_registry.cc
index caf04de..5430d392 100644
--- a/chrome/browser/extensions/extension_keybinding_registry.cc
+++ b/chrome/browser/extensions/extension_keybinding_registry.cc
@@ -7,6 +7,8 @@
#include <memory>
#include <utility>
+#include "base/functional/bind.h"
+#include "base/memory/weak_ptr.h"
#include "base/values.h"
#include "build/chromeos_buildflags.h"
#include "chrome/browser/extensions/extension_tab_util.h"
@@ -64,6 +66,51 @@
namespace extensions {
+namespace {
+
+// Dispatches the `commands.onCommand` event for `command` to the extension
+// with `extension_id` (if the extension is still enabled). `web_contents` is
+// the active tab (or null if there is no active tab).
+void DispatchCommandEvent(content::BrowserContext& browser_context,
+ const ExtensionId& extension_id,
+ const std::string& command,
+ content::WebContents* web_contents) {
+ const Extension* extension = ExtensionRegistry::Get(&browser_context)
+ ->enabled_extensions()
+ .GetByID(extension_id);
+ if (!extension) {
+ return;
+ }
+
+ auto args = base::MakeRefCounted<EventArgs>();
+ args->data.Append(command);
+
+ base::Value tab_value;
+ if (web_contents) {
+ // The action APIs (browserAction, pageAction, action) are only available
+ // to privileged extension contexts. As such, we deterministically know
+ // that the right context type here is privileged.
+ constexpr mojom::ContextType context_type =
+ mojom::ContextType::kPrivilegedExtension;
+ ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+ ExtensionTabUtil::GetScrubTabBehavior(extension, context_type,
+ web_contents);
+ tab_value = base::Value(ExtensionTabUtil::CreateTabObject(
+ web_contents, scrub_tab_behavior, extension)
+ .ToValue());
+ }
+ args->data.Append(std::move(tab_value));
+
+ auto event =
+ std::make_unique<Event>(events::COMMANDS_ON_COMMAND, kOnCommandEventName,
+ std::move(args), &browser_context);
+ event->user_gesture = EventRouter::UserGestureState::kEnabled;
+ EventRouter::Get(&browser_context)
+ ->DispatchEventToExtension(extension_id, std::move(event));
+}
+
+} // namespace
+
ExtensionKeybindingRegistry::ExtensionKeybindingRegistry(
content::BrowserContext* context,
TabListInterface* tab_list_interface,
@@ -237,48 +284,37 @@
if (!extension)
return;
- auto args = base::MakeRefCounted<EventArgs>();
- args->data.Append(command);
-
- base::Value tab_value;
+ content::WebContents* web_contents = nullptr;
if (tab_list_interface_) {
auto* active_tab = tab_list_interface_->GetActiveTab();
- content::WebContents* web_contents =
- active_tab ? active_tab->GetContents() : nullptr;
- // Grant before sending the event so that the permission is granted before
- // the extension acts on the command. NOTE: The Global Commands handler does
- // not set the TabListInterface as it deals only with named commands (not
- // page/browser actions that are associated with the current page directly).
- ActiveTabPermissionGranter* granter =
- web_contents ? ActiveTabPermissionGranter::FromWebContents(web_contents)
- : nullptr;
- if (granter && !accelerator.IsMediaKey()) {
- granter->GrantIfRequested(extension);
- }
-
- if (web_contents) {
- // The action APIs (browserAction, pageAction, action) are only available
- // to privileged extension contexts. As such, we deterministically know
- // that the right context type here is privileged.
- constexpr mojom::ContextType context_type =
- mojom::ContextType::kPrivilegedExtension;
- ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
- ExtensionTabUtil::GetScrubTabBehavior(extension, context_type,
- web_contents);
- tab_value = base::Value(ExtensionTabUtil::CreateTabObject(
- web_contents, scrub_tab_behavior, extension)
- .ToValue());
- }
+ web_contents = active_tab ? active_tab->GetContents() : nullptr;
}
- args->data.Append(std::move(tab_value));
-
- auto event =
- std::make_unique<Event>(events::COMMANDS_ON_COMMAND, kOnCommandEventName,
- std::move(args), browser_context_);
- event->user_gesture = EventRouter::UserGestureState::kEnabled;
- EventRouter::Get(browser_context_)
- ->DispatchEventToExtension(extension_id, std::move(event));
+ // Grant before sending the event so that the permission is granted before
+ // the extension acts on the command. NOTE: The Global Commands handler does
+ // not set the TabListInterface as it deals only with named commands (not
+ // page/browser actions that are associated with the current page directly).
+ ActiveTabPermissionGranter* granter =
+ web_contents && !accelerator.IsMediaKey()
+ ? ActiveTabPermissionGranter::FromWebContents(web_contents)
+ : nullptr;
+ if (!granter) {
+ DispatchCommandEvent(*browser_context_, extension_id, command,
+ web_contents);
+ return;
+ }
+ granter->GrantIfRequested(
+ extension,
+ base::BindOnce(
+ [](base::WeakPtr<content::WebContents> web_contents,
+ const ExtensionId& extension_id, const std::string& command) {
+ if (!web_contents) {
+ return;
+ }
+ DispatchCommandEvent(*web_contents->GetBrowserContext(),
+ extension_id, command, web_contents.get());
+ },
+ web_contents->GetWeakPtr(), extension_id, command));
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/context_menus/extension_context_menu_browsertest.cc b/chrome/browser/extensions/api/context_menus/extension_context_menu_browsertest.cc
index fe12f4e3..13c51ef 100644
--- a/chrome/browser/extensions/api/context_menus/extension_context_menu_browsertest.cc
+++ b/chrome/browser/extensions/api/context_menus/extension_context_menu_browsertest.cc
@@ -18,6 +18,8 @@
#include "base/strings/strcat.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
+#include "base/test/bind.h"
+#include "base/test/test_future.h"
#include "build/build_config.h"
#include "build/chromeos_buildflags.h"
#include "chrome/browser/extensions/browsertest_util.h"
@@ -28,18 +30,22 @@
#include "chrome/browser/renderer_context_menu/context_menu_test_util.h"
#include "chrome/browser/tab_list/tab_list_interface.h"
#include "chrome/common/channel_info.h"
+#include "chrome/common/extensions/api/context_menus.h"
#include "components/version_info/channel.h"
#include "content/public/browser/context_menu_params.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/event_router.h"
#include "extensions/browser/extension_api_frame_id_map.h"
#include "extensions/browser/extension_host_test_helper.h"
#include "extensions/browser/extension_registry.h"
+#include "extensions/browser/network_permissions_updater.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/state_store.h"
#include "extensions/browser/state_store_test_observer.h"
+#include "extensions/browser/test_event_router_observer.h"
#include "extensions/browser/test_management_policy.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension_set.h"
@@ -47,6 +53,7 @@
#include "extensions/common/mojom/view_type.mojom.h"
#include "extensions/test/extension_test_message_listener.h"
#include "extensions/test/result_catcher.h"
+#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
#include "ui/base/models/menu_model.h"
#include "ui/menus/simple_menu_model.h"
@@ -1026,5 +1033,85 @@
/*IsUseUnifiedLockedStateControllerEnabled=*/::testing::Bool()));
#endif
+class ExtensionContextMenuActiveTabBrowserTest
+ : public ExtensionContextMenuBrowserTest {
+ public:
+ void SetUpOnMainThread() override {
+ ExtensionContextMenuBrowserTest::SetUpOnMainThread();
+ host_resolver()->AddRule("*", "127.0.0.1");
+ }
+};
+
+// Tests that `contextMenus.onClicked` is not dispatched if the frame navigates
+// away (and is put into the back/forward cache) while the activeTab grant is
+// on its way to the network service. Otherwise, the `info` argument of the
+// event would describe the old document, but the `tab` argument would describe
+// the new document.
+IN_PROC_BROWSER_TEST_F(ExtensionContextMenuActiveTabBrowserTest,
+ NoClickEventAfterNavigationToBackForwardCache) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(R"(
+ {
+ "name": "NoClickEventAfterNavigationToBackForwardCache",
+ "version": "1.0",
+ "manifest_version": 3,
+ "background": {"service_worker": "background.js"},
+ "permissions": ["activeTab", "contextMenus"]
+ } )");
+ test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+ chrome.runtime.onInstalled.addListener(() => {
+ chrome.contextMenus.create({id: 'item', title: 'Item'}, () => {
+ chrome.test.sendMessage('created');
+ });
+ });
+ chrome.contextMenus.onClicked.addListener(() => {});
+ )");
+ ExtensionTestMessageListener created_listener("created");
+ const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+ ASSERT_TRUE(created_listener.WaitUntilSatisfied());
+
+ WebContents* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(content::NavigateToURL(
+ web_contents, embedded_test_server()->GetURL("a.com", "/title1.html")));
+ content::RenderFrameHostWrapper old_frame(
+ web_contents->GetPrimaryMainFrame());
+
+ // Click the menu item, while holding the network service update of the
+ // activeTab grant.
+ base::OnceClosure send_update;
+ NetworkPermissionsUpdater::HoldNextUpdateForTesting(
+ base::BindLambdaForTesting([&](base::OnceClosure held_update) {
+ send_update = std::move(held_update);
+ }));
+ TestEventRouterObserver event_observer(EventRouter::Get(profile()));
+ content::ContextMenuParams params;
+ params.page_url = old_frame->GetLastCommittedURL();
+ std::unique_ptr<PlatformContextMenu> menu =
+ CreateContextMenu(old_frame.get(), params);
+ ExecuteCommand(menu.get(), extension->id(), "item");
+ ASSERT_TRUE(send_update);
+
+ // Navigate cross-site, so that the old frame is put into the back/forward
+ // cache.
+ ASSERT_TRUE(content::NavigateToURL(
+ web_contents, embedded_test_server()->GetURL("b.com", "/title2.html")));
+ ASSERT_TRUE(old_frame->IsInLifecycleState(
+ content::RenderFrameHost::LifecycleState::kInBackForwardCache));
+
+ // Release the held update, and wait until the network service has processed
+ // it (network service updates are processed in FIFO order).
+ std::move(send_update).Run();
+ base::test::TestFuture<void> update_done;
+ NetworkPermissionsUpdater::UpdateExtension(
+ *profile(), *extension,
+ NetworkPermissionsUpdater::ContextSet::kAllRelatedContexts,
+ update_done.GetCallback());
+ ASSERT_TRUE(update_done.Wait());
+
+ EXPECT_FALSE(event_observer.events().contains(
+ api::context_menus::OnClicked::kEventName));
+}
} // namespace extensions
diff --git a/chrome/browser/extensions/api/declarative_net_request/declarative_net_request_browsertest.cc b/chrome/browser/extensions/api/declarative_net_request/declarative_net_request_browsertest.cc
index 25eeed4de..f8298f34 100644
--- a/chrome/browser/extensions/api/declarative_net_request/declarative_net_request_browsertest.cc
+++ b/chrome/browser/extensions/api/declarative_net_request/declarative_net_request_browsertest.cc
@@ -4241,7 +4241,7 @@
// Querying the badge text with the activeTab access for the current tab
// should provide the matched action count.
- active_tab_granter->GrantIfRequested(extension_1);
+ active_tab_granter->GrantIfRequested(extension_1, base::DoNothing());
EXPECT_EQ("1", query_badge_text(extension_1->id(), first_tab_id));
// Revoking activeTab should cause the placeholder text to be returned again
// since we don't have the declarativeNetRequestFeedback permission.
@@ -5232,7 +5232,7 @@
const Extension* dnr_extension = last_loaded_extension();
// Grant the activeTab permission for the second tab.
- active_tab_granter->GrantIfRequested(dnr_extension);
+ active_tab_granter->GrantIfRequested(dnr_extension, base::DoNothing());
int second_tab_id = ExtensionTabUtil::GetTabId(GetActiveWebContents());
diff --git a/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc b/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc
index 1337fd5..fd066e0 100644
--- a/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc
+++ b/chrome/browser/extensions/api/page_capture/page_capture_apitest.cc
@@ -5,6 +5,7 @@
#include <atomic>
#include "base/command_line.h"
+#include "base/functional/callback_helpers.h"
#include "base/memory/weak_ptr.h"
#include "base/strings/stringprintf.h"
#include "base/values.h"
@@ -239,7 +240,7 @@
// 2. Grant activeTab permission on the main tab and capture again.
ActiveTabPermissionGranter::FromWebContents(main_tab)->GrantIfRequested(
- extension2);
+ extension2, base::DoNothing());
std::string mhtml_with_active_tab = capture_tab_fn(tab_id);
EXPECT_THAT(mhtml_with_active_tab,
testing::Not(testing::StartsWith("ERROR:")));
diff --git a/chrome/browser/extensions/api/permissions/permissions_api_browsertest.cc b/chrome/browser/extensions/api/permissions/permissions_api_browsertest.cc
index 036c4d9..480e887 100644
--- a/chrome/browser/extensions/api/permissions/permissions_api_browsertest.cc
+++ b/chrome/browser/extensions/api/permissions/permissions_api_browsertest.cc
@@ -9,6 +9,7 @@
#include <string>
#include "base/auto_reset.h"
+#include "base/functional/callback_helpers.h"
#include "base/json/json_reader.h"
#include "base/json/json_writer.h"
#include "base/strings/string_number_conversions.h"
@@ -1217,7 +1218,7 @@
// Grant one-time host access.
ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension.get());
+ ->GrantIfRequested(extension.get(), base::DoNothing());
// Add host access request for requested.com. Request is invalid because
// extension already has host access (even if it's just one-time).
diff --git a/chrome/browser/extensions/api/tab_capture/tab_capture_apitest.cc b/chrome/browser/extensions/api/tab_capture/tab_capture_apitest.cc
index 5dba084..18bf360 100644
--- a/chrome/browser/extensions/api/tab_capture/tab_capture_apitest.cc
+++ b/chrome/browser/extensions/api/tab_capture/tab_capture_apitest.cc
@@ -8,6 +8,7 @@
#include "base/command_line.h"
#include "base/feature_list.h"
+#include "base/functional/callback_helpers.h"
#include "base/location.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/scoped_refptr.h"
@@ -181,7 +182,7 @@
web_contents->GetBrowserContext())->enabled_extensions().GetByID(
kExtensionId);
ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension);
+ ->GrantIfRequested(extension, base::DoNothing());
before_grant_permission.Reply("");
// Open a new tab and make sure capture is denied.
@@ -248,7 +249,7 @@
web_contents->GetBrowserContext())->enabled_extensions().GetByID(
kExtensionId);
ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension);
+ ->GrantIfRequested(extension, base::DoNothing());
before_open_tab.Reply("");
ResultCatcher catcher;
@@ -292,7 +293,7 @@
void GrantCapturePermission(const Extension* extension,
content::WebContents* web_contents) {
ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension);
+ ->GrantIfRequested(extension, base::DoNothing());
}
std::string BuildArgs(int target_tab_id, int consumer_tab_id) {
@@ -515,7 +516,7 @@
ActiveTabPermissionGranter::FromWebContents(web_contents);
// It doesn't seem to work to grant permissions for both extensions at the
// same time. We start with extension_a.
- perm_granter->GrantIfRequested(extension_a);
+ perm_granter->GrantIfRequested(extension_a, base::DoNothing());
// Set up success listeners.
ExtensionTestMessageListener extension_a_success("success");
@@ -528,7 +529,7 @@
extension_a_ready.Reset();
ASSERT_TRUE(extension_a_ready.WaitUntilSatisfied());
- perm_granter->GrantIfRequested(extension_b);
+ perm_granter->GrantIfRequested(extension_b, base::DoNothing());
// To reproduce crbug.com/40869705, we have extension_b spam tab capture
// requests until one or the other extension successfully captures the tab.
@@ -675,7 +676,7 @@
content::WebContents* const web_contents =
content::WebContents::FromRenderFrameHost(main_frame);
ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension());
+ ->GrantIfRequested(extension(), base::DoNothing());
return web_contents;
}
@@ -766,7 +767,7 @@
content::WebContents* const web_contents =
content::WebContents::FromRenderFrameHost(main_frame);
ActiveTabPermissionGranter::FromWebContents(web_contents)
- ->GrantIfRequested(extension());
+ ->GrantIfRequested(extension(), base::DoNothing());
CaptureTab();
EXPECT_EQ(tab_capture_errors::kCannotCapturePage, GetCaptureResult());
@@ -798,7 +799,7 @@
EXPECT_TRUE(content::WaitForLoadStop(popup_contents));
ActiveTabPermissionGranter::FromWebContents(popup_contents)
- ->GrantIfRequested(extension());
+ ->GrantIfRequested(extension(), base::DoNothing());
CaptureTab();
EXPECT_EQ(tab_capture_errors::kCannotCapturePage, GetCaptureResult());
diff --git a/chrome/browser/extensions/api/tabs/tabs_apitest.cc b/chrome/browser/extensions/api/tabs/tabs_apitest.cc
index 283e55d2..f2123493 100644
--- a/chrome/browser/extensions/api/tabs/tabs_apitest.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_apitest.cc
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include "base/functional/callback_helpers.h"
#include "base/strings/stringprintf.h"
#include "base/strings/to_string.h"
#include "base/test/bind.h"
@@ -887,7 +888,7 @@
extensions::ActiveTabPermissionGranter* granter =
extensions::ActiveTabPermissionGranter::FromWebContents(web_contents);
ASSERT_TRUE(granter);
- granter->GrantIfRequested(extension);
+ granter->GrantIfRequested(extension, base::DoNothing());
extensions::ResultCatcher catcher;
listener.Reply("");
diff --git a/chrome/browser/extensions/extension_action_runner_unittest.cc b/chrome/browser/extensions/extension_action_runner_unittest.cc
index 5cf2eea..e4faf72 100644
... (truncated)
Original Bug Report
TOCTOU in ActiveTabPermissionGranter leads to stale renderer permissions
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 race condition in ActiveTabPermissionGranter allows stale activeTab permissions to be delivered to a renderer after a cross-origin navigation. This occurs because the browser process fails to re-verify the grant state after an asynchronous Mojo round-trip to the network service, leading to a permission desync between the browser and renderer.
Affected files:
extensions/browser/permissions/active_tab_permission_granter.ccextensions/renderer/dispatcher.ccchrome/browser/extensions/extension_action_runner.cc
Estimated timestamp from git blame: 2026-04-13
Summary
A potential Time-of-Check Time-of-Use (TOCTOU) vulnerability exists in ActiveTabPermissionGranter. When an extension is granted activeTab permissions, the browser process performs an asynchronous update to the network service before notifying the renderer. If a cross-origin navigation commits during this interval, the permissions are synchronously revoked browser-side, but the delayed notification callback still proceeds to deliver the stale permissions to the new renderer process.
Root Cause Analysis
In ActiveTabPermissionGranter::GrantIfRequested (extensions/browser/permissions/active_tab_permission_granter.cc:134), the browser synchronously records the permission grant and updates the browser-side PermissionsData. It then initiates an asynchronous update to the CORS origin access list via SetCorsOriginAccessList (line 190). The completion callback, ActiveTabPermissionGranter::NotifyGranted (line 202), is triggered only after a Mojo round-trip to the network service.
Between the initiation of the asynchronous call and the execution of NotifyGranted, other UI-thread tasks—specifically a cross-origin navigation—can occur. When a navigation commits, DidFinishNavigation (line 248) calls ClearGrantedExtensionsAndNotify, which clears the browser-side permissions, sends a ClearTabSpecificPermissions message to renderers, and removes the extension from granted_extensions_ (line 343).
When NotifyGranted eventually runs, it performs insufficient validation (lines 207-212). It checks if the extension is enabled and if the tab has a visible entry, but it fails to verify that the extension is still present in granted_extensions_ for that tab. Consequently, it sends an UpdateTabSpecificPermissions message (containing the previous origin’s permissions) to the renderer process of the new, cross-origin page (line 230). This message re-installs the stale permissions in the renderer-side PermissionsData and updates Blink’s WebSecurityPolicy allowlist.
Potential Impact
While browser-side security checks in PermissionsData (used by APIs like scripting.executeScript) correctly re-verify permissions against the browser’s internal state, the renderer-side state becomes corrupted. This leads to several potential security issues:
- Same-Origin Policy (SOP) Bypass: An extension content script running on the new origin can bypass the SOP to access DOM elements of the previous (victim) origin if frames of that origin are still present in the same process. This is particularly relevant on platforms with relaxed Site Isolation, such as Android.
- State Desync: The renderer-side
PermissionsDataincorrectly believes it has host access to the previous origin on the current tab. - Observer Errors: Observers such as
ExtensionActionRunnerare notified of a grant for a page the user did not approve, potentially leading to logic errors in extension UI.
Suggested Reproductions Steps (Potential)
- Install an extension that uses the
activeTabpermission. - Navigate to
https://victim.comand then trigger a navigation tohttps://attacker.com. - During the navigation transition, trigger the extension (e.g., via a toolbar icon click) so
GrantIfRequestedis called forvictim.comjust as the navigation commits. - Observe that the navigation to
attacker.comcompletes, triggering the browser to revoke thevictim.compermissions. - Observe that the delayed
NotifyGrantedexecutes after the revocation, sendingUpdateTabSpecificPermissionsforvictim.comto the renderer hostingattacker.com. - Verify that an extension content script on
attacker.comcan now access frames fromvictim.comvia the DOM.
Recommended Fix
Modify ActiveTabPermissionGranter::NotifyGranted to verify that the extension still has a valid grant before proceeding with the renderer update:
void ActiveTabPermissionGranter::NotifyGranted(
scoped_refptr<const Extension> extension,
URLPatternSet new_hosts) {
...
// Add a check to ensure the extension still has the grant.
if (!IsGranted(extension.get())) {
return;
}
...
}
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
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.