CVE-2026-113264
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/chrome_extension_frame_host.cc |
modified | |
ExtensionActionRunnerPrerenderBrowserTestchrome/browser/extensions/extension_action_runner_browsertest.cc |
modified | |
ifchrome/browser/extensions/extension_action_runner_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/extension_action_runner_browsertest.ccchrome/browser/extensions/chrome_extension_frame_host.ccextensions/renderer/extension_injection_host.cc
Patch
From 65257bf872bb39e2bd438ca3d48584eada4132a5 Mon Sep 17 00:00:00 2001 From: Eva Su <[email protected]> Date: Fri, 18 Sep 2026 17:58:10 -0700 Subject: [PATCH] [Extensions] Reject script input requests from non-primary frames ExtensionActionRunner manages blocked actions and user consent exclusively for the visible primary main frame. When extension host permissions are withheld, ExtensionInjectionHost in the renderer asks for permission via ChromeExtensionFrameHost::RequestScriptInjectionPermission. However, prerendered main frames are outermost main frames, so the renderer previously forwarded permission requests from prerendered frames to the browser. ExtensionActionRunner evaluated these requests against the active primary page URL and queued them as pending scripts. When the user clicked the extension action on the primary page, pending scripts queued for background prerendered frames (which could be cross-origin) were also executed. This CL fixes the issue by treating withheld script access as denied in the renderer when the document is prerendering, matching the treatment of subframes and out-of-tab frames and adding a browser-side check in ChromeExtensionFrameHost::RequestScriptInjectionPermission to reject requests from non-primary main frames. Fixed: 501786099 Change-Id: I2aeb46f041325772737f31c35505208e913a9583 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8407049 Reviewed-by: Kelvin Jiang <[email protected]> Commit-Queue: Eva Su <[email protected]> Cr-Commit-Position: refs/heads/main@{#1701281} --- diff --git a/chrome/browser/extensions/chrome_extension_frame_host.cc b/chrome/browser/extensions/chrome_extension_frame_host.cc index c3bdc5a..ca7377c 100644 --- a/chrome/browser/extensions/chrome_extension_frame_host.cc +++ b/chrome/browser/extensions/chrome_extension_frame_host.cc @@ -38,9 +38,9 @@ mojom::InjectionType script_type, mojom::RunLocation run_location, RequestScriptInjectionPermissionCallback callback) { + content::RenderFrameHost& render_frame_host = receivers_.CurrentTargetFrame(); if (!crx_file::id_util::IdIsValid(extension_id)) { - content::RenderProcessHost* render_process = - receivers_.CurrentTargetFrame().GetProcess(); + content::RenderProcessHost* render_process = render_frame_host.GetProcess(); if (render_process) { bad_message::ReceivedBadMessage( render_process, @@ -49,6 +49,14 @@ return; } + // We only handle blocked actions for the primary main frame since there is no + // way to surface permission requests for non-primary frames (such as + // subframes or prerendered frames) to the user. + if (!render_frame_host.IsInPrimaryMainFrame()) { + std::move(callback).Run(false); + return; + } + ExtensionActionRunner* runner = ExtensionActionRunner::GetForWebContents(web_contents_); if (!runner) { diff --git a/extensions/renderer/extension_injection_host.cc b/extensions/renderer/extension_injection_host.cc index 53f0326..e89596d1 100644 --- a/extensions/renderer/extension_injection_host.cc +++ b/extensions/renderer/extension_injection_host.cc @@ -12,6 +12,7 @@ #include "extensions/renderer/extension_web_view_helper.h" #include "extensions/renderer/renderer_extension_registry.h" #include "pdf/buildflags.h" +#include "third_party/blink/public/web/web_document.h" #include "third_party/blink/public/web/web_local_frame.h" #if BUILDFLAG(ENABLE_PDF) @@ -102,10 +103,15 @@ tab_id, nullptr /* ignore error */); } + blink::WebDocument document = web_local_frame->GetDocument(); + const bool document_is_prerendering = + !document.IsNull() && document.IsPrerendering(); if (access == PermissionsData::PageAccess::kWithheld && - (tab_id == -1 || !render_frame->GetWebFrame()->IsOutermostMainFrame())) { - // Note: we don't consider ACCESS_WITHHELD for child frames or for frames - // outside of tabs because there is nowhere to surface a request. + (tab_id == -1 || !web_local_frame->IsOutermostMainFrame() || + document_is_prerendering)) { + // Note: we don't consider ACCESS_WITHHELD for child frames, for frames + // outside of tabs, or for prerendered frames because there is nowhere to + // surface a request. // TODO(devlin): We should ask for permission somehow. crbug.com/40419472. access = PermissionsData::PageAccess::kDenied; } diff --git a/chrome/browser/extensions/extension_action_runner_browsertest.cc b/chrome/browser/extensions/extension_action_runner_browsertest.cc index cbf744e..e6e68e9 100644 --- a/chrome/browser/extensions/extension_action_runner_browsertest.cc +++ b/chrome/browser/extensions/extension_action_runner_browsertest.cc @@ -15,6 +15,7 @@ #include "base/run_loop.h" #include "base/strings/stringprintf.h" #include "base/test/scoped_feature_list.h" +#include "base/test/test_future.h" #include "chrome/browser/extensions/blocked_action_waiter.h" #include "chrome/browser/extensions/extension_browsertest.h" #include "chrome/browser/extensions/tab_helper.h" @@ -24,18 +25,25 @@ #include "content/public/browser/web_contents.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" +#include "content/public/test/prerender_test_util.h" #include "extensions/browser/browsertest_util.h" #include "extensions/browser/extension_action.h" +#include "extensions/browser/extension_frame_host.h" +#include "extensions/browser/extension_web_contents_observer.h" #include "extensions/browser/permissions/active_tab_permission_granter.h" #include "extensions/browser/permissions/scripting_permissions_modifier.h" #include "extensions/browser/permissions/site_permissions_helper.h" #include "extensions/browser/permissions_manager.h" #include "extensions/buildflags/buildflags.h" #include "extensions/common/extension_features.h" +#include "extensions/common/mojom/injection_type.mojom-shared.h" +#include "extensions/common/mojom/run_location.mojom-shared.h" #include "extensions/test/extension_test_message_listener.h" #include "extensions/test/permissions_manager_waiter.h" #include "extensions/test/test_extension_dir.h" #include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/http_request.h" +#include "net/test/embedded_test_server/http_response.h" #include "testing/gtest/include/gtest/gtest.h" static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE)); @@ -524,4 +532,147 @@ DONT_WITHHOLD_PERMISSIONS, DOES_NOT_REQUIRE_CONSENT); } +// Tests that script injection requests from prerendered frames are not queued +// or executed when the user runs an extension action on the primary page. +class ExtensionActionRunnerPrerenderBrowserTest + : public ExtensionActionRunnerBrowserTest { + public: + ExtensionActionRunnerPrerenderBrowserTest() + : prerender_helper_(base::BindRepeating( + &ExtensionActionRunnerPrerenderBrowserTest::GetActiveWebContents, + base::Unretained(this))) {} + + void SetUp() override { + prerender_helper_.RegisterServerRequestMonitor(embedded_test_server()); + embedded_test_server()->RegisterRequestHandler(base::BindRepeating( + &ExtensionActionRunnerPrerenderBrowserTest::HandleRequest, + base::Unretained(this))); + ExtensionActionRunnerBrowserTest::SetUp(); + } + + void SetUpOnMainThread() override { + host_resolver()->AddRule("*", "127.0.0.1"); + ASSERT_TRUE(embedded_test_server()->Start()); + ExtensionActionRunnerBrowserTest::SetUpOnMainThread(); + } + + protected: + content::test::PrerenderTestHelper& prerender_helper() { + return prerender_helper_; + } + + private: + std::unique_ptr<net::test_server::HttpResponse> HandleRequest( + const net::test_server::HttpRequest& request) { + if (request.relative_url == "/page/primary.html") { + auto resp = std::make_unique<net::test_server::BasicHttpResponse>(); + resp->set_code(net::HTTP_OK); + resp->set_content_type("text/html"); + resp->set_content("<!doctype html><title>primary</title>primary"); + return resp; + } + if (request.relative_url == "/page/prerender.html") { + auto resp = std::make_unique<net::test_server::BasicHttpResponse>(); + resp->set_code(net::HTTP_OK); + resp->set_content_type("text/html"); + resp->AddCustomHeader("Supports-Loading-Mode", "credentialed-prerender"); + resp->set_content("<!doctype html><title>prerender</title>prerender"); + return resp; + } + return nullptr; + } + + content::test::PrerenderTestHelper prerender_helper_; +}; + +IN_PROC_BROWSER_TEST_F(ExtensionActionRunnerPrerenderBrowserTest, + PrerenderedCrossOriginPageDoesNotRunPendingScripts) { + TestExtensionDir ext_dir; + ext_dir.WriteManifest(R"({ + "name": "script injection test", + "version": "1", + "manifest_version": 3, + "host_permissions": ["<all_urls>"], + "permissions": ["activeTab"], + "content_scripts": [{ + "matches": ["<all_urls>"], + "js": ["cs.js"], + "run_at": "document_idle" + }] + })"); + ext_dir.WriteFile(FILE_PATH_LITERAL("cs.js"), + "document.title = 'INJECTED@' + location.hostname;"); + const Extension* extension = LoadExtension(ext_dir.UnpackedPath()); + ASSERT_TRUE(extension); + ScriptingPermissionsModifier(profile(), extension) + .SetWithholdHostPermissions(true); + + content::WebContents* web_contents = GetActiveWebContents(); + ASSERT_TRUE(web_contents); + ExtensionActionRunner* runner = + ExtensionActionRunner::GetForWebContents(web_contents); + ASSERT_TRUE(runner); + + const GURL primary_url = + embedded_test_server()->GetURL("a.test", "/page/primary.html"); + { + BlockedActionWaiter waiter(runner); + ASSERT_TRUE(NavigateToURL(web_contents, primary_url)); + waiter.Wait(); + } + EXPECT_TRUE(RunAllPendingInRenderer(web_contents)); + EXPECT_TRUE(runner->WantsToRun(extension)); + EXPECT_EQ("primary", content::EvalJs(web_contents->GetPrimaryMainFrame(), + "document.title")); + + const GURL prerender_url = + embedded_test_server()->GetURL("b.a.test", "/page/prerender.html"); + const int requests_before_prerender = runner->num_page_requests(); + content::PrerenderHostId host_id = + prerender_helper().AddPrerender(prerender_url); + EXPECT_FALSE(host_id.is_null()); + content::RenderFrameHost* prerender_rfh = + prerender_helper().GetPrerenderedMainFrameHost(host_id); + ASSERT_TRUE(prerender_rfh); + EXPECT_NE(url::Origin::Create(primary_url), + url::Origin::Create(prerender_url)); + EXPECT_EQ(runner->num_page_requests(), requests_before_prerender); + EXPECT_EQ("prerender", content::EvalJs(prerender_rfh, "document.title")); + + // Verify that even if a renderer in a non-primary main frame attempts to + // request script injection permission directly, the browser-side check in + // ChromeExtensionFrameHost::RequestScriptInjectionPermission rejects it. + auto* extension_frame_host = + ExtensionWebContentsObserver::GetForWebContents(web_contents) + ->extension_frame_host_for_testing(); + extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting( + prerender_rfh); + base::test::TestFuture<bool> permission_future; + extension_frame_host->RequestScriptInjectionPermission( + extension->id(), mojom::InjectionType::kContentScript, + mojom::RunLocation::kDocumentIdle, permission_future.GetCallback()); + EXPECT_FALSE(permission_future.Get()); + extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting( + nullptr); + EXPECT_EQ(runner->num_page_requests(), requests_before_prerender); + + EXPECT_EQ(primary_url, web_contents->GetLastCommittedURL()); + content::TitleWatcher title_watcher(web_contents, u"[email protected]"); + runner->RunAction(extension, /*grant_tab_permissions=*/true); + + // The visible primary page was granted permission, so its script runs. + EXPECT_EQ(u"[email protected]", title_watcher.WaitAndGetTitle()); + + // The prerendered cross-origin frame should NOT have run the script. + EXPECT_EQ("prerender", content::EvalJs(prerender_rfh, "document.title")); + + // Navigate to activate the prerendered page. + prerender_helper().NavigatePrimaryPage(prerender_url); + EXPECT_EQ(prerender_url, web_contents->GetLastCommittedURL()); + + // Even after activation, the script should not have run on the cross-origin + // page. + EXPECT_EQ("prerender", content::EvalJs(web_contents->GetPrimaryMainFrame(), + "document.title")); +} } // namespace extensions
Regression Test / PoC
diff --git a/chrome/browser/extensions/extension_action_runner_browsertest.cc b/chrome/browser/extensions/extension_action_runner_browsertest.cc
index cbf744e..e6e68e9 100644
--- a/chrome/browser/extensions/extension_action_runner_browsertest.cc
+++ b/chrome/browser/extensions/extension_action_runner_browsertest.cc
@@ -15,6 +15,7 @@
#include "base/run_loop.h"
#include "base/strings/stringprintf.h"
#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
#include "chrome/browser/extensions/blocked_action_waiter.h"
#include "chrome/browser/extensions/extension_browsertest.h"
#include "chrome/browser/extensions/tab_helper.h"
@@ -24,18 +25,25 @@
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/prerender_test_util.h"
#include "extensions/browser/browsertest_util.h"
#include "extensions/browser/extension_action.h"
+#include "extensions/browser/extension_frame_host.h"
+#include "extensions/browser/extension_web_contents_observer.h"
#include "extensions/browser/permissions/active_tab_permission_granter.h"
#include "extensions/browser/permissions/scripting_permissions_modifier.h"
#include "extensions/browser/permissions/site_permissions_helper.h"
#include "extensions/browser/permissions_manager.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension_features.h"
+#include "extensions/common/mojom/injection_type.mojom-shared.h"
+#include "extensions/common/mojom/run_location.mojom-shared.h"
#include "extensions/test/extension_test_message_listener.h"
#include "extensions/test/permissions_manager_waiter.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/http_request.h"
+#include "net/test/embedded_test_server/http_response.h"
#include "testing/gtest/include/gtest/gtest.h"
static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -524,4 +532,147 @@
DONT_WITHHOLD_PERMISSIONS, DOES_NOT_REQUIRE_CONSENT);
}
+// Tests that script injection requests from prerendered frames are not queued
+// or executed when the user runs an extension action on the primary page.
+class ExtensionActionRunnerPrerenderBrowserTest
+ : public ExtensionActionRunnerBrowserTest {
+ public:
+ ExtensionActionRunnerPrerenderBrowserTest()
+ : prerender_helper_(base::BindRepeating(
+ &ExtensionActionRunnerPrerenderBrowserTest::GetActiveWebContents,
+ base::Unretained(this))) {}
+
+ void SetUp() override {
+ prerender_helper_.RegisterServerRequestMonitor(embedded_test_server());
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &ExtensionActionRunnerPrerenderBrowserTest::HandleRequest,
+ base::Unretained(this)));
+ ExtensionActionRunnerBrowserTest::SetUp();
+ }
+
+ void SetUpOnMainThread() override {
+ host_resolver()->AddRule("*", "127.0.0.1");
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ExtensionActionRunnerBrowserTest::SetUpOnMainThread();
+ }
+
+ protected:
+ content::test::PrerenderTestHelper& prerender_helper() {
+ return prerender_helper_;
+ }
+
+ private:
+ std::unique_ptr<net::test_server::HttpResponse> HandleRequest(
+ const net::test_server::HttpRequest& request) {
+ if (request.relative_url == "/page/primary.html") {
+ auto resp = std::make_unique<net::test_server::BasicHttpResponse>();
+ resp->set_code(net::HTTP_OK);
+ resp->set_content_type("text/html");
+ resp->set_content("<!doctype html><title>primary</title>primary");
+ return resp;
+ }
+ if (request.relative_url == "/page/prerender.html") {
+ auto resp = std::make_unique<net::test_server::BasicHttpResponse>();
+ resp->set_code(net::HTTP_OK);
+ resp->set_content_type("text/html");
+ resp->AddCustomHeader("Supports-Loading-Mode", "credentialed-prerender");
+ resp->set_content("<!doctype html><title>prerender</title>prerender");
+ return resp;
+ }
+ return nullptr;
+ }
+
+ content::test::PrerenderTestHelper prerender_helper_;
+};
+
+IN_PROC_BROWSER_TEST_F(ExtensionActionRunnerPrerenderBrowserTest,
+ PrerenderedCrossOriginPageDoesNotRunPendingScripts) {
+ TestExtensionDir ext_dir;
+ ext_dir.WriteManifest(R"({
+ "name": "script injection test",
+ "version": "1",
+ "manifest_version": 3,
+ "host_permissions": ["<all_urls>"],
+ "permissions": ["activeTab"],
+ "content_scripts": [{
+ "matches": ["<all_urls>"],
+ "js": ["cs.js"],
+ "run_at": "document_idle"
+ }]
+ })");
+ ext_dir.WriteFile(FILE_PATH_LITERAL("cs.js"),
+ "document.title = 'INJECTED@' + location.hostname;");
+ const Extension* extension = LoadExtension(ext_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+ ScriptingPermissionsModifier(profile(), extension)
+ .SetWithholdHostPermissions(true);
+
+ content::WebContents* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(web_contents);
+ ExtensionActionRunner* runner =
+ ExtensionActionRunner::GetForWebContents(web_contents);
+ ASSERT_TRUE(runner);
+
+ const GURL primary_url =
+ embedded_test_server()->GetURL("a.test", "/page/primary.html");
+ {
+ BlockedActionWaiter waiter(runner);
+ ASSERT_TRUE(NavigateToURL(web_contents, primary_url));
+ waiter.Wait();
+ }
+ EXPECT_TRUE(RunAllPendingInRenderer(web_contents));
+ EXPECT_TRUE(runner->WantsToRun(extension));
+ EXPECT_EQ("primary", content::EvalJs(web_contents->GetPrimaryMainFrame(),
+ "document.title"));
+
+ const GURL prerender_url =
+ embedded_test_server()->GetURL("b.a.test", "/page/prerender.html");
+ const int requests_before_prerender = runner->num_page_requests();
+ content::PrerenderHostId host_id =
+ prerender_helper().AddPrerender(prerender_url);
+ EXPECT_FALSE(host_id.is_null());
+ content::RenderFrameHost* prerender_rfh =
+ prerender_helper().GetPrerenderedMainFrameHost(host_id);
+ ASSERT_TRUE(prerender_rfh);
+ EXPECT_NE(url::Origin::Create(primary_url),
+ url::Origin::Create(prerender_url));
+ EXPECT_EQ(runner->num_page_requests(), requests_before_prerender);
+ EXPECT_EQ("prerender", content::EvalJs(prerender_rfh, "document.title"));
+
+ // Verify that even if a renderer in a non-primary main frame attempts to
+ // request script injection permission directly, the browser-side check in
+ // ChromeExtensionFrameHost::RequestScriptInjectionPermission rejects it.
+ auto* extension_frame_host =
+ ExtensionWebContentsObserver::GetForWebContents(web_contents)
+ ->extension_frame_host_for_testing();
+ extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting(
+ prerender_rfh);
+ base::test::TestFuture<bool> permission_future;
+ extension_frame_host->RequestScriptInjectionPermission(
+ extension->id(), mojom::InjectionType::kContentScript,
+ mojom::RunLocation::kDocumentIdle, permission_future.GetCallback());
+ EXPECT_FALSE(permission_future.Get());
+ extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting(
+ nullptr);
+ EXPECT_EQ(runner->num_page_requests(), requests_before_prerender);
+
+ EXPECT_EQ(primary_url, web_contents->GetLastCommittedURL());
+ content::TitleWatcher title_watcher(web_contents, u"[email protected]");
+ runner->RunAction(extension, /*grant_tab_permissions=*/true);
+
+ // The visible primary page was granted permission, so its script runs.
+ EXPECT_EQ(u"[email protected]", title_watcher.WaitAndGetTitle());
+
+ // The prerendered cross-origin frame should NOT have run the script.
+ EXPECT_EQ("prerender", content::EvalJs(prerender_rfh, "document.title"));
+
+ // Navigate to activate the prerendered page.
+ prerender_helper().NavigatePrimaryPage(prerender_url);
+ EXPECT_EQ(prerender_url, web_contents->GetLastCommittedURL());
+
+ // Even after activation, the script should not have run on the cross-origin
+ // page.
+ EXPECT_EQ("prerender", content::EvalJs(web_contents->GetPrimaryMainFrame(),
+ "document.title"));
+}
} // namespace extensions
Original Bug Report
Extension click-to-script bypass via same-site cross-origin prerendering
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 Chrome Security team.
Overview: A potential logic flaw in ExtensionActionRunner may allow a same-site cross-origin prerendered page to bypass the “click-to-script” permission gate. Because script injection permissions are managed at the WebContents level and checked against the visible URL, a user clicking an extension on an attacker page can inadvertently authorize script injection into a cross-origin prerendered victim page.
Affected files:
chrome/browser/extensions/extension_action_runner.ccextensions/renderer/extension_injection_host.cc
Estimated timestamp from git blame: 2026-02-10
Summary
A logic error exists in Chromium’s extension permission model where script injection requests are managed per-WebContents but evaluated against the currently visible URL. Under the Multiple Page Architecture (MPArch), a same-site cross-origin prerendered page shares a WebContents with its initiator. An attacker can use this to queue a script injection request from a prerendered victim page, which is then inadvertently authorized when the user clicks the extension action on the attacker’s visible page.
Prerequisites
- An attacker controls a page (e.g.,
https://evil.example.com). - A sensitive victim page exists on the same site but a different origin (e.g.,
https://login.example.com) and opts into credentialed prerendering via theSupports-Loading-Mode: credentialed-prerenderHTTP header. - The user has installed an extension that uses “click-to-script” (withheld) site access.
Potential Attack Steps
Note: These steps are based on static code analysis. Our tooling does not yet execute code to provide a working proof-of-concept.
- Prerender Initiation: The user visits the attacker’s page (
evil.example.com). The page uses the Speculation Rules API to initiate a prerender of the victim page (login.example.com). Because the victim returns thecredentialed-prerenderheader, the prerender is allowed. - Shared State: The prerendered victim page is loaded in a separate
RenderFrameHostbut shares the sameWebContentsobject as the visible attacker page. - Renderer Privilege Check: The extension attempts to inject a content script into the prerendered victim page. In
extensions/renderer/extension_injection_host.cc:105-111, the renderer normally blocks hidden subframes from requesting permission. However, because the prerendered page is its own frame tree,IsOutermostMainFrame()returnstrue. The request is allowed to proceed askWithheld. - Browser Receives IPC: The renderer sends a
RequestScriptInjectionPermissionIPC to the browser. - Faulty URL Validation: In
chrome/browser/extensions/extension_action_runner.cc:260,RequiresUserConsentForScriptInjectionevaluates the permission request againstweb_contents()->GetVisibleURL(). This checks the attacker’s URL instead of the requesting prerendered frame’s URL. The request is appended to thepending_scripts_queue. - User Interaction: The attacker’s page convinces the user to click the extension icon in the toolbar.
- Indiscriminate Permission Grant: The click triggers
ExtensionActionRunner::RunBlockedActions, which grantsactiveTabpermission to the visible origin (evil.example.com). It then callsRunPendingScriptsForExtension. - Flushing the Queue:
RunPendingScriptsForExtensionindiscriminately executes all pending Mojo callbacks in theWebContentsqueue withgranted = true(chrome/browser/extensions/extension_action_runner.cc:321). It does not verify which frame or origin requested the scripts. - Payload Execution: The victim’s renderer receives the
granted = trueIPC callback (ScriptInjection::HandlePermission).ScriptInjection::Injectruns immediately without re-verifying the local permission state. The script executes within the victim’s context, potentially allowing DOM access and data exfiltration. - Activation: The attacker navigates the user to the prerendered page, activating it. The injected script has already compromised the session.
Suggested Fix
- Browser-Side Validation:
PendingScript(inextension_action_runner.h) should store the origin orRenderFrameHostidentifier of the frame that requested the injection. WhenRunPendingScriptsForExtensionis called, it should only execute callbacks for scripts whose requesting frame matches the newly granted permissions (i.e., the visible main frame and its same-origin subframes). - Renderer-Side Hardening:
ScriptInjection::Inject(inextensions/renderer/script_injection.cc) should consider re-evaluatingCanExecuteOnFrameimmediately before injection to ensure permissions are still valid for its specific context, providing defense-in-depth against delayed or improperly routed IPC grants.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.