CVE-2026-12457
Overview
Files Changed
chrome/browser/extensions/script_injection_tracker_browsertest.ccextensions/browser/script_executor.cc
Patch
From 2e4412b4632880203755104d93be611b12b3740a Mon Sep 17 00:00:00 2001
From: Andrea Orru <andreaorru@chromium.org>
Date: Thu, 04 Jun 2026 15:04:14 -0700
Subject: [PATCH] [Extensions] Skip error documents during subframe script injection
When an extension executes a script with allFrames: true,
ScriptExecutor::Handler::MaybeAddSubFrame previously failed to check if
a frame is an error document. This allowed a compromised renderer to
trick ScriptInjectionTracker into believing an extension is executing
scripts inside its process, bypassing process isolation boundaries.
This CL adds an IsErrorDocument() check in MaybeAddSubFrame() to
consistently skip error pages in descendants during programmatic
injections, matching the explicit-frameId execution path.
Fixed: 517153117
Change-Id: I7731c5b3d252dbb9bcb89e365595e4044a5ac01c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7902906
Commit-Queue: Andrea Orru <andreaorru@chromium.org>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641964}
---
diff --git a/chrome/browser/extensions/script_injection_tracker_browsertest.cc b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
index 6e229b8..17e744a5 100644
--- a/chrome/browser/extensions/script_injection_tracker_browsertest.cc
+++ b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
@@ -2855,4 +2855,68 @@
*child_frame->GetProcess(), extension->id()));
}
+// Tests that error pages, such as those shown when a frame is blocked via CSP,
+// do not get counted as having scripts injected into them during programmatic
+// script execution with allFrames: true.
+// Regression test for crbug.com/517153117.
+IN_PROC_BROWSER_TEST_F(
+ ScriptInjectionTrackerBrowserTest,
+ CSPBlockedFrameDoesNotGrantPrivilege_ProgrammaticScript) {
+ // Set up ControllableHttpResponse to control the attacker page response.
+ std::string attacker_path = "/page.html";
+ net::test_server::ControllableHttpResponse attacker_response(
+ embedded_test_server(), attacker_path);
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // Install a test extension that has scripting permission and host
+ // permissions.
+ TestExtensionDir dir;
+ const char kManifestTemplate[] = R"(
+ {
+ "name": "ScriptInjectionTrackerBrowserTest - CSP Blocked Programmatic",
+ "version": "1.0",
+ "manifest_version": 3,
+ "permissions": [ "scripting" ],
+ "host_permissions": ["*://victim.com/*"],
+ "background": { "service_worker": "worker.js" }
+ } )";
+
+ dir.WriteManifest(kManifestTemplate);
+ dir.WriteFile(FILE_PATH_LITERAL("worker.js"), "");
+
+ const Extension* extension = LoadExtension(dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ // Navigate to attacker.com with an error subframe to victim.com.
+ GURL attacker_url =
+ embedded_test_server()->GetURL("attacker.com", "/page.html");
+ GURL victim_url = embedded_test_server()->GetURL("victim.com", "/page.html");
+ content::RenderFrameHost* child_frame =
+ OpenPageWithErrorSubFrame(attacker_response, attacker_url, victim_url);
+ ASSERT_TRUE(child_frame);
+
+ int tab_id = ExtensionTabUtil::GetTabId(GetActiveWebContents());
+
+ // Execute a programmatic script injection with allFrames: true.
+ const char kScript[] = R"(
+ chrome.scripting.executeScript({
+ target: {tabId: $1, allFrames: true},
+ func: () => {}
+ }, () => {
+ chrome.test.sendScriptResult('executed');
+ });
+ )";
+ std::string script = content::JsReplace(kScript, tab_id);
+
+ base::Value result = BackgroundScriptExecutor::ExecuteScript(
+ profile(), extension->id(), script,
+ BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
+ EXPECT_EQ("executed", result.GetString());
+
+ // The tracker should still not think that the attacker process has run
+ // content scripts, because the frame is an error document.
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *child_frame->GetProcess(), extension->id()));
+}
+
} // namespace extensions
diff --git a/extensions/browser/script_executor.cc b/extensions/browser/script_executor.cc
index ea557e1..2a3dac9 100644
--- a/extensions/browser/script_executor.cc
+++ b/extensions/browser/script_executor.cc
@@ -208,6 +208,16 @@
return content::RenderFrameHost::FrameIterationAction::kContinue;
}
+ // Avoid injecting into error documents (e.g. frames blocked by CSP) and
+ // their subtrees, matching the explicit-frameId execution path validation.
+ // This prevents compromised renderers from tricking ScriptInjectionTracker
+ // into believing an extension is executing scripts inside an
+ // attacker-controlled process.
+ // See https://crbug.com/517153117.
+ if (frame->IsErrorDocument()) {
+ return content::RenderFrameHost::FrameIterationAction::kSkipChildren;
+ }
+
if (!is_web_view_ &&
host_id_.type == mojom::HostID::HostType::kExtensions) {
// TODO(crbug.com/502262220): We do permission checks in at least three
Regression Test / PoC
diff --git a/chrome/browser/extensions/script_injection_tracker_browsertest.cc b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
index 6e229b8..17e744a5 100644
--- a/chrome/browser/extensions/script_injection_tracker_browsertest.cc
+++ b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
@@ -2855,4 +2855,68 @@
*child_frame->GetProcess(), extension->id()));
}
+// Tests that error pages, such as those shown when a frame is blocked via CSP,
+// do not get counted as having scripts injected into them during programmatic
+// script execution with allFrames: true.
+// Regression test for crbug.com/517153117.
+IN_PROC_BROWSER_TEST_F(
+ ScriptInjectionTrackerBrowserTest,
+ CSPBlockedFrameDoesNotGrantPrivilege_ProgrammaticScript) {
+ // Set up ControllableHttpResponse to control the attacker page response.
+ std::string attacker_path = "/page.html";
+ net::test_server::ControllableHttpResponse attacker_response(
+ embedded_test_server(), attacker_path);
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // Install a test extension that has scripting permission and host
+ // permissions.
+ TestExtensionDir dir;
+ const char kManifestTemplate[] = R"(
+ {
+ "name": "ScriptInjectionTrackerBrowserTest - CSP Blocked Programmatic",
+ "version": "1.0",
+ "manifest_version": 3,
+ "permissions": [ "scripting" ],
+ "host_permissions": ["*://victim.com/*"],
+ "background": { "service_worker": "worker.js" }
+ } )";
+
+ dir.WriteManifest(kManifestTemplate);
+ dir.WriteFile(FILE_PATH_LITERAL("worker.js"), "");
+
+ const Extension* extension = LoadExtension(dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ // Navigate to attacker.com with an error subframe to victim.com.
+ GURL attacker_url =
+ embedded_test_server()->GetURL("attacker.com", "/page.html");
+ GURL victim_url = embedded_test_server()->GetURL("victim.com", "/page.html");
+ content::RenderFrameHost* child_frame =
+ OpenPageWithErrorSubFrame(attacker_response, attacker_url, victim_url);
+ ASSERT_TRUE(child_frame);
+
+ int tab_id = ExtensionTabUtil::GetTabId(GetActiveWebContents());
+
+ // Execute a programmatic script injection with allFrames: true.
+ const char kScript[] = R"(
+ chrome.scripting.executeScript({
+ target: {tabId: $1, allFrames: true},
+ func: () => {}
+ }, () => {
+ chrome.test.sendScriptResult('executed');
+ });
+ )";
+ std::string script = content::JsReplace(kScript, tab_id);
+
+ base::Value result = BackgroundScriptExecutor::ExecuteScript(
+ profile(), extension->id(), script,
+ BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
+ EXPECT_EQ("executed", result.GetString());
+
+ // The tracker should still not think that the attacker process has run
+ // content scripts, because the frame is an error document.
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *child_frame->GetProcess(), extension->id()));
+}
+
} // namespace extensions
Original Bug Report
ScriptExecutor subframe injection misses IsErrorDocument check, allowing tracker bypass
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: When an extension executes a script with allFrames: true, ScriptExecutor::Handler::MaybeAddSubFrame fails to check if a frame is an error document. This allows a compromised renderer to trick the browser’s ScriptInjectionTracker into believing an extension is executing scripts inside its process. Consequently, the attacker can bypass process isolation boundaries to access privileged extension resources.
Affected files:
extensions/browser/script_executor.cc
Estimated timestamp from git blame: 2026-04-13
Root Cause Analysis
In extensions/browser/script_executor.cc, when executing a script with allFrames: true, ScriptExecutor::Handler recursively traverses all descendant frames of the target page. While the explicit-frameId path correctly performs an IsErrorDocument() check to avoid injecting into error pages, the subframe traversal path in MaybeAddSubFrame misses this validation:
// extensions/browser/script_executor.cc (MaybeAddSubFrame)
if (!frame->IsRenderFrameLive() ||
std::ranges::contains(pending_render_frames_, frame)) {
return content::RenderFrameHost::FrameIterationAction::kContinue;
}
// Missing: if (frame->IsErrorDocument()) check!
Because HasPermissionToInjectIntoFrame relies on RenderFrameHost::GetLastCommittedURL(), and error documents preserve the failed navigation URL (rather than using a chrome-error:// URL), the permission check evaluates the target URL instead of the actual error context.
Under specific conditions, a blocked navigation to a target site commits an error document directly inside the initiator’s process (e.g., the attacker’s process) rather than isolating it. In particular, renderer-initiated subframe navigations failing with a request-blocked error (e.g., net::ERR_BLOCKED_BY_CSP) commit in the current site instance (ErrorPageProcess::kCurrentProcess).
Potential Threat Model and Trigger Path
Please note: Since our analysis is purely static and we currently do not have the capability to run code or execute a dynamic proof-of-concept, the following describes a potential attack path.
-
Preconditions:
- An attacker controls a third-party origin
https://thirdparty.examplerunning in a site-isolated renderer process $P_{attacker}$. - A benign extension with host permissions for
https://target.example/*is installed and programmatically executes scripts withallFrames: true(e.g., in response to a tab update).
- An attacker controls a third-party origin
-
Triggering the Vulnerability:
- The user navigates to
https://target.example/page(process $P_{target}$), which embeds an iframe pointing tohttps://thirdparty.example/widget($P_{attacker}$). - The attacker-controlled page at
https://thirdparty.example/widgetis served with the HTTP headerContent-Security-Policy: frame-src 'none'. - The attacker’s script programmatically inserts a grandchild iframe to
https://target.example/x. - Due to the CSP restriction, this navigation fails with
net::ERR_BLOCKED_BY_CSPand commits directly within the grandchild frame inside the attacker’s process $P_{attacker}$ (since it is a subframe and the error is a request-blocked error). ItsGetLastCommittedURL()returnshttps://target.example/x, andIsErrorDocument()istrue.
- The user navigates to
-
Exploiting the State:
- The benign extension invokes
executeScript({allFrames: true}).ScriptExecutor::Handlerwalks the frames. - It visits the grandchild frame. Since
IsErrorDocument()is not checked inMaybeAddSubFrame(), andGetLastCommittedURL()ishttps://target.example/x,HasPermissionToInjectIntoFrame()returnstrue. - The frame is added to
pending_render_frames_, which triggersScriptInjectionTracker::WillExecuteCode()and registers the extension as having executed a script in $P_{attacker}$. - Now, a compromised renderer in $P_{attacker}$ can successfully pass browser-side validation checks like
CanRendererActOnBehalfOfExtension()orIsValidMessagingSource(). This allows the attacker to escalate privileges to access extension storage (viachrome.storage.local) or dispatch internal messages to the extension’s background page/service worker.
- The benign extension invokes
Suggested Remediation
In extensions/browser/script_executor.cc, update MaybeAddSubFrame to explicitly check and skip error documents:
if (frame->IsErrorDocument()) {
return content::RenderFrameHost::FrameIterationAction::kContinue;
}
This ensures that error pages in descendants are consistently ignored during subframe programmatic injections, matching the explicit-frameId execution path.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.