CVE-2026-17739
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchchrome/browser/extensions/extension_action_runner.cc |
modified | |
TEST_Fchrome/browser/extensions/extension_action_runner_unittest.cc |
modified |
Files Changed
chrome/browser/extensions/extension_action_runner.ccchrome/browser/extensions/extension_action_runner_unittest.cc
Patch
From c5a70174d6d62736ce2cd2839354737657c02858 Mon Sep 17 00:00:00 2001
From: Devlin Cronin <rdevlin.cronin@chromium.org>
Date: Fri, 26 Jun 2026 16:03:09 -0700
Subject: [PATCH] [Extensions] Check GetLastCommittedURL() in ExtensionActionRunner
ExtensionActionRunner checks whether user consent is needed for running
a script on a page. Before this CL, it uses GetVisibleURL() -- at the
time, this was vaguely intentional, since that corresponds to the URL
the user sees (and thus, might grant access to). However, this
introduces potential TOCTOU issues with racing renderers and the site
the script is going to inject in will be the last committed URL.
Use that instead, and add a regression test.
Bug: 498353463
Change-Id: I536bd2c2bc5bd7cbd662d4e48443ea377c371c1b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8008626
Commit-Queue: Devlin Cronin <rdevlin.cronin@chromium.org>
Reviewed-by: Eva Su <evasu@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1653511}
---
diff --git a/chrome/browser/extensions/extension_action_runner.cc b/chrome/browser/extensions/extension_action_runner.cc
index a18b16c..cb8b251 100644
--- a/chrome/browser/extensions/extension_action_runner.cc
+++ b/chrome/browser/extensions/extension_action_runner.cc
@@ -257,7 +257,7 @@
return PermissionsData::PageAccess::kAllowed;
}
- GURL url = web_contents()->GetVisibleURL();
+ GURL url = web_contents()->GetLastCommittedURL();
int tab_id = sessions::SessionTabHelper::IdForTab(web_contents()).id();
switch (type) {
case mojom::InjectionType::kContentScript:
diff --git a/chrome/browser/extensions/extension_action_runner_unittest.cc b/chrome/browser/extensions/extension_action_runner_unittest.cc
index d35203e..ef5798d 100644
--- a/chrome/browser/extensions/extension_action_runner_unittest.cc
+++ b/chrome/browser/extensions/extension_action_runner_unittest.cc
@@ -323,6 +323,49 @@
EXPECT_FALSE(runner()->WantsToRun(extension));
}
+// Tests that a pending (uncommitted) navigation does not affect whether the
+// extension requires user consent for the currently committed page.
+TEST_F(ExtensionActionRunnerUnitTest,
+ PendingNavigationDoesNotAffectUserConsent) {
+ const Extension* extension = AddExtension();
+ ASSERT_TRUE(extension);
+
+ const GURL withheld_url("https://www.withheld.com");
+ const GURL granted_url("https://www.granted.com");
+
+ // Grant the extension permission to always run on `granted_url`. It still
+ // requires user consent on `withheld_url`.
+ ScriptingPermissionsModifier(profile(), extension)
+ .GrantHostPermission(granted_url);
+
+ NavigateAndCommit(withheld_url);
+ EXPECT_TRUE(RequiresUserConsent(extension));
+
+ // Start (but don't commit) a browser-initiated navigation to `granted_url`.
+ // The committed page is still `withheld_url`, so user consent should still
+ // be required to inject into it.
+ std::unique_ptr<content::NavigationSimulator> navigation =
+ content::NavigationSimulator::CreateBrowserInitiated(granted_url,
+ web_contents());
+ navigation->Start();
+ ASSERT_EQ(granted_url, web_contents()->GetVisibleURL());
+ ASSERT_EQ(withheld_url, web_contents()->GetLastCommittedURL());
+ EXPECT_TRUE(RequiresUserConsent(extension));
+
+ // The reverse: committed page is `granted_url`, with a pending navigation
+ // to `withheld_url`. The extension should not require user consent.
+ navigation->Commit();
+ ASSERT_EQ(granted_url, web_contents()->GetLastCommittedURL());
+ EXPECT_FALSE(RequiresUserConsent(extension));
+
+ navigation = content::NavigationSimulator::CreateBrowserInitiated(
+ withheld_url, web_contents());
+ navigation->Start();
+ ASSERT_EQ(withheld_url, web_contents()->GetVisibleURL());
+ ASSERT_EQ(granted_url, web_contents()->GetLastCommittedURL());
+ EXPECT_FALSE(RequiresUserConsent(extension));
+}
+
// Test that queueing multiple pending injections, and then accepting, triggers
// them all.
TEST_F(ExtensionActionRunnerUnitTest, MultiplePendingInjection) {
Regression Test / PoC
diff --git a/chrome/browser/extensions/extension_action_runner_unittest.cc b/chrome/browser/extensions/extension_action_runner_unittest.cc
index d35203e..ef5798d 100644
--- a/chrome/browser/extensions/extension_action_runner_unittest.cc
+++ b/chrome/browser/extensions/extension_action_runner_unittest.cc
@@ -323,6 +323,49 @@
EXPECT_FALSE(runner()->WantsToRun(extension));
}
+// Tests that a pending (uncommitted) navigation does not affect whether the
+// extension requires user consent for the currently committed page.
+TEST_F(ExtensionActionRunnerUnitTest,
+ PendingNavigationDoesNotAffectUserConsent) {
+ const Extension* extension = AddExtension();
+ ASSERT_TRUE(extension);
+
+ const GURL withheld_url("https://www.withheld.com");
+ const GURL granted_url("https://www.granted.com");
+
+ // Grant the extension permission to always run on `granted_url`. It still
+ // requires user consent on `withheld_url`.
+ ScriptingPermissionsModifier(profile(), extension)
+ .GrantHostPermission(granted_url);
+
+ NavigateAndCommit(withheld_url);
+ EXPECT_TRUE(RequiresUserConsent(extension));
+
+ // Start (but don't commit) a browser-initiated navigation to `granted_url`.
+ // The committed page is still `withheld_url`, so user consent should still
+ // be required to inject into it.
+ std::unique_ptr<content::NavigationSimulator> navigation =
+ content::NavigationSimulator::CreateBrowserInitiated(granted_url,
+ web_contents());
+ navigation->Start();
+ ASSERT_EQ(granted_url, web_contents()->GetVisibleURL());
+ ASSERT_EQ(withheld_url, web_contents()->GetLastCommittedURL());
+ EXPECT_TRUE(RequiresUserConsent(extension));
+
+ // The reverse: committed page is `granted_url`, with a pending navigation
+ // to `withheld_url`. The extension should not require user consent.
+ navigation->Commit();
+ ASSERT_EQ(granted_url, web_contents()->GetLastCommittedURL());
+ EXPECT_FALSE(RequiresUserConsent(extension));
+
+ navigation = content::NavigationSimulator::CreateBrowserInitiated(
+ withheld_url, web_contents());
+ navigation->Start();
+ ASSERT_EQ(withheld_url, web_contents()->GetVisibleURL());
+ ASSERT_EQ(granted_url, web_contents()->GetLastCommittedURL());
+ EXPECT_FALSE(RequiresUserConsent(extension));
+}
+
// Test that queueing multiple pending injections, and then accepting, triggers
// them all.
TEST_F(ExtensionActionRunnerUnitTest, MultiplePendingInjection) {
Original Bug Report
Potential Extension Permission Bypass via TOCTOU in ExtensionActionRunner
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 logic vulnerability in ExtensionActionRunner allows extensions to bypass the “on click” (withheld host permissions) boundary. By timing a script injection request during a pending browser-initiated navigation, an extension can execute scripts on restricted origins because the browser incorrectly checks permissions against the pending URL rather than the committed URL.
Affected files:
chrome/browser/extensions/extension_action_runner.ccchrome/browser/extensions/chrome_extension_frame_host.cc
Estimated timestamp from git blame: 2014-07-07
Summary
A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in ExtensionActionRunner::RequiresUserConsentForScriptInjection() where the browser evaluates script injection permissions against the URL returned by web_contents()->GetVisibleURL() instead of the frame’s committed URL. This allows a malicious extension with withheld permissions for a victim site to bypass the user consent requirement by triggering or waiting for a browser-initiated navigation to a site for which it already has permissions.
Technical Details
When an extension attempts to inject a script into a frame where its permissions are withheld (e.g., set to “On click”), the renderer pauses the injection and asks the browser for permission via the RequestScriptInjectionPermission IPC.
The browser handles this in ExtensionActionRunner::RequiresUserConsentForScriptInjection (chrome/browser/extensions/extension_action_runner.cc:250). At line 260, the browser fetches the URL to validate against:
GURL url = web_contents()->GetVisibleURL();
In Chromium, GetVisibleURL() returns the pending navigation URL for browser-initiated navigations (like typing in the omnibox, clicking a bookmark, or using certain APIs like chrome.search.query) before the navigation has actually committed. However, the renderer process requesting the injection is still actively hosting the previous, committed document.
If an extension requests injection during this window, the browser sees the pending URL, concludes that the extension has permission (if it has access to the pending URL’s origin), and grants the request. The renderer then blindly trusts this grant and injects the script into the still-active victim frame.
Potential Attack Scenario (Unverified)
Note: These steps are based on static analysis and have not been executed in a live environment.
- Setup: An attacker publishes a malicious extension requesting
host_permissionsfor a victim site (https://victim.example/*) and an allowed site (https://allowed.example/*). - Configuration: The user installs the extension and configures site access for
victim.exampleto “On click” (withheld permissions). The extension retains full access toallowed.example. - Navigation: The user navigates a tab to
https://victim.example/. The page loads and the navigation commits. - Trigger: A browser-initiated navigation to
https://allowed.example/is triggered in the same tab (e.g., via the omnibox or programmatically via an API likechrome.search.querywhich lacks theis_renderer_initiatedflag). - Race Condition: The extension detects the start of the navigation (e.g., via
chrome.webNavigation.onBeforeNavigate). At this exact moment,NavigationControllerImpl::GetVisibleEntry()returns the pending entry forallowed.example. - Injection Request: The extension immediately calls
chrome.scripting.executeScript({target: {tabId: tab.id}, files: ["payload.js"]}). - Renderer Check: The renderer checks the committed URL (
victim.example), sees permissions are withheld, and sendsRequestScriptInjectionPermissionto the browser. - Browser Bypass: The browser calls
RequiresUserConsentForScriptInjection, evaluatesweb_contents()->GetVisibleURL()(which is nowallowed.example), finds permissions are granted, and repliestrue. - Execution: The renderer receives the grant and executes
payload.jswithin thevictim.exampleframe, bypassing the user’s “On click” restriction.
Suggested Fix
Modify ExtensionActionRunner::RequiresUserConsentForScriptInjection to check permissions against the committed URL of the frame requesting the injection, rather than the GetVisibleURL() of the WebContents. This aligns with other permission checks, such as those in scripting::CanAccessTarget and ActiveTabPermissionGranter::GrantIfRequested.
// In chrome/browser/extensions/extension_action_runner.cc:260
// Instead of:
// GURL url = web_contents()->GetVisibleURL();
// Use the last committed URL or the URL associated with the specific frame requesting injection.
GURL url = web_contents()->GetLastCommittedURL();
Evaluated with Chrome root at commit: e9e0fcbb690b1a8c1a26c81c2a9ea23d6e178368
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.