CVE-2026-17806
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/script_injection_tracker_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/script_injection_tracker_browsertest.cc
Patch
From e6acff459aa25281e9fc0db159d9e6fa8437cb1a Mon Sep 17 00:00:00 2001
From: Devlin Cronin <rdevlin.cronin@chromium.org>
Date: Mon, 01 Jun 2026 18:40:56 -0700
Subject: [PATCH] [Extensions] Don't treat error pages as commits for script injections II
ScriptInjectionTracker tracks the pages that are committed in order to
determine if an extension script may have run in them. There's a[nother]
bug where it doesn't properly filter out error pages, which appear to
commit to the given origin, but are distinct and don't allow script
injection. This would result in potentially recording a script as
having injected into a page, even though it didn't.
This was mostly fixed in crrev.com/9fb89ac98d9d9, but we missed a case
where we add frames matching origins from a "rescan" of matching
scripts, which can be triggered by e.g. adding a dynamic script.
Fix this and add a regression test.
Bug: 516433058
Change-Id: I49be2806113fb89c23f10127440101dbfb4ee45a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7885222
Reviewed-by: Łukasz Anforowicz <lukasza@chromium.org>
Commit-Queue: Devlin Cronin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1639850}
---
diff --git a/chrome/browser/extensions/script_injection_tracker_browsertest.cc b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
index 712cb9f..6e229b8 100644
--- a/chrome/browser/extensions/script_injection_tracker_browsertest.cc
+++ b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
@@ -63,6 +63,8 @@
namespace extensions {
+namespace {
+
// Asks the |extension_id| to inject |content_script| into |web_contents|.
void ExecuteProgrammaticContentScriptNoWait(content::WebContents* web_contents,
const ExtensionId& extension_id,
@@ -107,6 +109,8 @@
EXPECT_EQ("\"Hello from acking script!\"", msg);
}
+} // namespace
+
// Test suite covering `extensions::ScriptInjectionTracker` from
// //extensions/browser/script_injection_tracker.h.
//
@@ -137,6 +141,62 @@
return original_web_contents->GetPrimaryMainFrame()->GetProcess() !=
new_web_contents->GetPrimaryMainFrame()->GetProcess();
}
+
+ // Opens a new tab to `parent_url` and adds a subframe, which will point to
+ // `child_url` -- but ensures the child frame fails to load and instead is an
+ // error page. `response` is a controllable response for the `parent_url`
+ // (sadly, this must be passed in, because these need to be instantiated
+ // before the test server starts).
+ // Returns the child frame.
+ content::RenderFrameHost* OpenPageWithErrorSubFrame(
+ net::test_server::ControllableHttpResponse& response,
+ const GURL& parent_url,
+ const GURL& child_url) {
+ // Navigate to the parent URL.
+ content::TestNavigationObserver nav_observer(parent_url);
+ nav_observer.StartWatchingNewWebContents();
+ ui_test_utils::NavigateToURLWithDisposition(
+ browser(), parent_url, WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui_test_utils::BROWSER_TEST_WAIT_FOR_TAB);
+ response.WaitForRequest();
+
+ // Respond with a CSP that will block an iframe loading the child URL.
+ static constexpr char kHtmlTemplate[] =
+ R"(<html>
+ <body>
+ <iframe src="%s"></iframe>
+ </body>
+ </html>)";
+ std::string response_body =
+ base::StringPrintf(kHtmlTemplate, child_url.spec().c_str());
+ std::vector<std::string> headers = {
+ "Content-Security-Policy: frame-src 'none'"};
+ response.Send(net::HTTP_OK, "text/html", response_body, {}, headers);
+ response.Done();
+ nav_observer.WaitForNavigationFinished();
+
+ content::WebContents* tab = GetActiveWebContents();
+ content::WaitForLoadStop(tab);
+ content::RenderFrameHost* main_frame = tab->GetPrimaryMainFrame();
+
+ // Find the child frame.
+ content::RenderFrameHost* child_frame =
+ content::ChildFrameAt(main_frame, 0);
+ if (!child_frame) {
+ ADD_FAILURE() << "Failed to navigate to child: " << child_url;
+ return nullptr;
+ }
+
+ // The child frame should have failed to navigate to victim.com and
+ // committed an error page instead.
+ EXPECT_TRUE(child_frame->IsErrorDocument());
+ EXPECT_EQ(child_url, child_frame->GetLastCommittedURL());
+
+ // The child frame is hosted in the same process as the main frame.
+ EXPECT_EQ(main_frame->GetProcess(), child_frame->GetProcess());
+
+ return child_frame;
+ }
};
// Helper class for executing a content script right before handling a DidCommit
@@ -2686,7 +2746,7 @@
IN_PROC_BROWSER_TEST_F(ScriptInjectionTrackerBrowserTest,
CSPBlockedFrameDoesNotGrantPrivilege) {
// Set up ControllableHttpResponse to control the attacker page response.
- std::string attacker_path = "/attacker.html";
+ std::string attacker_path = "/page.html";
net::test_server::ControllableHttpResponse attacker_response(
embedded_test_server(), attacker_path);
ASSERT_TRUE(embedded_test_server()->Start());
@@ -2711,56 +2771,19 @@
const Extension* extension = LoadExtension(dir.UnpackedPath());
ASSERT_TRUE(extension);
- // Navigate to attacker.com/attacker.html.
+ // Navigate to attacker.com with an error subframe to victim.com.
GURL attacker_url =
- embedded_test_server()->GetURL("attacker.com", attacker_path);
- content::TestNavigationObserver nav_observer(attacker_url);
- nav_observer.StartWatchingNewWebContents();
- // Don't wait for the load to finish; we need to respond with the custom
- // response.
- ui_test_utils::NavigateToURLWithDisposition(
- browser(), attacker_url, WindowOpenDisposition::NEW_FOREGROUND_TAB,
- ui_test_utils::BROWSER_TEST_WAIT_FOR_TAB);
- attacker_response.WaitForRequest();
-
- // Respond with a CSP that will block an iframe loading victim.com.
- static constexpr char kHtmlTemplate[] =
- R"(<html>
- <body>
- <iframe src="%s"></iframe>
- </body>
- </html>)";
- GURL victim_url("http://victim.com/title1.html");
- std::string response_body =
- base::StringPrintf(kHtmlTemplate, victim_url.spec().c_str());
- std::vector<std::string> headers = {
- "Content-Security-Policy: frame-src 'none'"};
- attacker_response.Send(net::HTTP_OK, "text/html", response_body, {}, headers);
- attacker_response.Done();
- nav_observer.WaitForNavigationFinished();
-
- content::WebContents* tab = GetActiveWebContents();
- content::WaitForLoadStop(tab);
- content::RenderFrameHost* main_frame = tab->GetPrimaryMainFrame();
-
- // Find the child frame.
- content::RenderFrameHost* child_frame = content::ChildFrameAt(main_frame, 0);
+ 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);
- // The child frame should have failed to navigate to victim.com and committed
- // an error page instead.
- EXPECT_TRUE(child_frame->IsErrorDocument());
- EXPECT_EQ(victim_url, child_frame->GetLastCommittedURL());
-
- // The child frame is hosted in the same process as the main frame, since it's
- // an error page.
- EXPECT_EQ(main_frame->GetProcess(), child_frame->GetProcess());
-
// The tracker should NOT think that the attacker process (which hosts the
// main frame and the error page) has run content scripts from the extension,
// even though the iframe was targeted to victim.com.
EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
- *main_frame->GetProcess(), extension->id()));
+ *child_frame->GetProcess(), extension->id()));
// Double-check that the script didn't run in the frame.
EXPECT_EQ(
@@ -2768,4 +2791,68 @@
content::EvalJs(child_frame, "self.didInject || 'did not inject';"));
}
+// 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 for after a rescan
+// (e.g. due to dynamic script registration).
+// Regression test for crbug.com/516433058.
+IN_PROC_BROWSER_TEST_F(
+ ScriptInjectionTrackerBrowserTest,
+ CSPBlockedFrameDoesNotGrantPrivilege_RescanWithDynamicScripts) {
+ // 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());
+
Regression Test / PoC
diff --git a/chrome/browser/extensions/script_injection_tracker_browsertest.cc b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
index 712cb9f..6e229b8 100644
--- a/chrome/browser/extensions/script_injection_tracker_browsertest.cc
+++ b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
@@ -63,6 +63,8 @@
namespace extensions {
+namespace {
+
// Asks the |extension_id| to inject |content_script| into |web_contents|.
void ExecuteProgrammaticContentScriptNoWait(content::WebContents* web_contents,
const ExtensionId& extension_id,
@@ -107,6 +109,8 @@
EXPECT_EQ("\"Hello from acking script!\"", msg);
}
+} // namespace
+
// Test suite covering `extensions::ScriptInjectionTracker` from
// //extensions/browser/script_injection_tracker.h.
//
@@ -137,6 +141,62 @@
return original_web_contents->GetPrimaryMainFrame()->GetProcess() !=
new_web_contents->GetPrimaryMainFrame()->GetProcess();
}
+
+ // Opens a new tab to `parent_url` and adds a subframe, which will point to
+ // `child_url` -- but ensures the child frame fails to load and instead is an
+ // error page. `response` is a controllable response for the `parent_url`
+ // (sadly, this must be passed in, because these need to be instantiated
+ // before the test server starts).
+ // Returns the child frame.
+ content::RenderFrameHost* OpenPageWithErrorSubFrame(
+ net::test_server::ControllableHttpResponse& response,
+ const GURL& parent_url,
+ const GURL& child_url) {
+ // Navigate to the parent URL.
+ content::TestNavigationObserver nav_observer(parent_url);
+ nav_observer.StartWatchingNewWebContents();
+ ui_test_utils::NavigateToURLWithDisposition(
+ browser(), parent_url, WindowOpenDisposition::NEW_FOREGROUND_TAB,
+ ui_test_utils::BROWSER_TEST_WAIT_FOR_TAB);
+ response.WaitForRequest();
+
+ // Respond with a CSP that will block an iframe loading the child URL.
+ static constexpr char kHtmlTemplate[] =
+ R"(<html>
+ <body>
+ <iframe src="%s"></iframe>
+ </body>
+ </html>)";
+ std::string response_body =
+ base::StringPrintf(kHtmlTemplate, child_url.spec().c_str());
+ std::vector<std::string> headers = {
+ "Content-Security-Policy: frame-src 'none'"};
+ response.Send(net::HTTP_OK, "text/html", response_body, {}, headers);
+ response.Done();
+ nav_observer.WaitForNavigationFinished();
+
+ content::WebContents* tab = GetActiveWebContents();
+ content::WaitForLoadStop(tab);
+ content::RenderFrameHost* main_frame = tab->GetPrimaryMainFrame();
+
+ // Find the child frame.
+ content::RenderFrameHost* child_frame =
+ content::ChildFrameAt(main_frame, 0);
+ if (!child_frame) {
+ ADD_FAILURE() << "Failed to navigate to child: " << child_url;
+ return nullptr;
+ }
+
+ // The child frame should have failed to navigate to victim.com and
+ // committed an error page instead.
+ EXPECT_TRUE(child_frame->IsErrorDocument());
+ EXPECT_EQ(child_url, child_frame->GetLastCommittedURL());
+
+ // The child frame is hosted in the same process as the main frame.
+ EXPECT_EQ(main_frame->GetProcess(), child_frame->GetProcess());
+
+ return child_frame;
+ }
};
// Helper class for executing a content script right before handling a DidCommit
@@ -2686,7 +2746,7 @@
IN_PROC_BROWSER_TEST_F(ScriptInjectionTrackerBrowserTest,
CSPBlockedFrameDoesNotGrantPrivilege) {
// Set up ControllableHttpResponse to control the attacker page response.
- std::string attacker_path = "/attacker.html";
+ std::string attacker_path = "/page.html";
net::test_server::ControllableHttpResponse attacker_response(
embedded_test_server(), attacker_path);
ASSERT_TRUE(embedded_test_server()->Start());
@@ -2711,56 +2771,19 @@
const Extension* extension = LoadExtension(dir.UnpackedPath());
ASSERT_TRUE(extension);
- // Navigate to attacker.com/attacker.html.
+ // Navigate to attacker.com with an error subframe to victim.com.
GURL attacker_url =
- embedded_test_server()->GetURL("attacker.com", attacker_path);
- content::TestNavigationObserver nav_observer(attacker_url);
- nav_observer.StartWatchingNewWebContents();
- // Don't wait for the load to finish; we need to respond with the custom
- // response.
- ui_test_utils::NavigateToURLWithDisposition(
- browser(), attacker_url, WindowOpenDisposition::NEW_FOREGROUND_TAB,
- ui_test_utils::BROWSER_TEST_WAIT_FOR_TAB);
- attacker_response.WaitForRequest();
-
- // Respond with a CSP that will block an iframe loading victim.com.
- static constexpr char kHtmlTemplate[] =
- R"(<html>
- <body>
- <iframe src="%s"></iframe>
- </body>
- </html>)";
- GURL victim_url("http://victim.com/title1.html");
- std::string response_body =
- base::StringPrintf(kHtmlTemplate, victim_url.spec().c_str());
- std::vector<std::string> headers = {
- "Content-Security-Policy: frame-src 'none'"};
- attacker_response.Send(net::HTTP_OK, "text/html", response_body, {}, headers);
- attacker_response.Done();
- nav_observer.WaitForNavigationFinished();
-
- content::WebContents* tab = GetActiveWebContents();
- content::WaitForLoadStop(tab);
- content::RenderFrameHost* main_frame = tab->GetPrimaryMainFrame();
-
- // Find the child frame.
- content::RenderFrameHost* child_frame = content::ChildFrameAt(main_frame, 0);
+ 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);
- // The child frame should have failed to navigate to victim.com and committed
- // an error page instead.
- EXPECT_TRUE(child_frame->IsErrorDocument());
- EXPECT_EQ(victim_url, child_frame->GetLastCommittedURL());
-
- // The child frame is hosted in the same process as the main frame, since it's
- // an error page.
- EXPECT_EQ(main_frame->GetProcess(), child_frame->GetProcess());
-
// The tracker should NOT think that the attacker process (which hosts the
// main frame and the error page) has run content scripts from the extension,
// even though the iframe was targeted to victim.com.
EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
- *main_frame->GetProcess(), extension->id()));
+ *child_frame->GetProcess(), extension->id()));
// Double-check that the script didn't run in the frame.
EXPECT_EQ(
@@ -2768,4 +2791,68 @@
content::EvalJs(child_frame, "self.didInject || 'did not inject';"));
}
+// 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 for after a rescan
+// (e.g. due to dynamic script registration).
+// Regression test for crbug.com/516433058.
+IN_PROC_BROWSER_TEST_F(
+ ScriptInjectionTrackerBrowserTest,
+ CSPBlockedFrameDoesNotGrantPrivilege_RescanWithDynamicScripts) {
+ // 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 Rescan",
+ "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"), "");
+ dir.WriteFile(FILE_PATH_LITERAL("content_script.js"),
+ "self.didInject = 'injected';");
+
+ 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);
+
+ // Now trigger rescan by registering a dynamic script.
+ const char kScript[] = R"(
+ chrome.scripting.registerContentScripts([{
+ id: 'script1',
+ matches: ['*://victim.com/*'],
+ js: ['content_script.js'],
+ runAt: 'document_start'
+ }], () => {
+ chrome.test.sendScriptResult('registered');
+ });
+ )";
+ base::Value result = BackgroundScriptExecutor::ExecuteScript(
+ profile(), extension->id(), kScript,
+ BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
+ EXPECT_EQ("registered", 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
ScriptInjectionTracker bypass via error-page URL spoofing in blocked subframes
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 compromised renderer can spoof the committed URL of a blocked subframe error page, which is stored in the RenderFrameHost. During a subsequent tracker rescan, ScriptInjectionTracker::AddMatchingScriptsToProcess evaluates this spoofed URL without filtering out error documents. This potentially allows a compromised renderer to bypass browser-side IPC verification and act on behalf of extension content scripts.
Affected files:
extensions/browser/script_injection_tracker.cc
Estimated timestamp from git blame: 2023-12-08
Root Cause Analysis
In extensions/browser/script_injection_tracker.cc, the function AddMatchingScriptsToProcess is responsible for scanning the frames of a RenderProcessHost and registering which extensions are allowed to run content scripts in that process:
void AddMatchingScriptsToProcess(const Extension& extension,
content::RenderProcessHost& process) {
...
process.ForEachRenderFrameHost([&](content::RenderFrameHost* frame) {
const GURL& url = frame->GetLastCommittedURL();
if (!any_frame_matches_content_scripts) {
any_frame_matches_content_scripts =
DoWebViewScriptsMatch(extension, *frame) ||
DoStaticContentScriptsMatch(extension, *frame, url) ||
DoDynamicContentScriptsMatch(extension, *frame, url);
}
...
});
if (any_frame_matches_content_scripts) {
process_data.AddScript(ScriptType::kContentScript, extension.id());
}
}
During a navigation’s initial commit phase, ScriptInjectionTracker explicitly ignores error pages (e.g., in ReadyToCommitNavigation and DidFinishNavigation). However, when a tracker rescan is triggered at a later point (such as during dynamic script updates or permission changes), AddMatchingScriptsToProcess iterates over all current RenderFrameHost instances and matches each frame’s GetLastCommittedURL() without checking if the frame is hosting an error document.
Furthermore, under RenderFrameHostImpl::ValidateDidCommitParams (in content/browser/renderer_host/render_frame_host_impl.cc), when a renderer-initiated subframe navigation fails with a blocked request error (such as net::ERR_BLOCKED_BY_CSP), ShouldBypassSecurityChecksForErrorPage returns true. Consequently, the browser skips ValidateURLAndOrigin validation. This allows a compromised renderer to commit an arbitrary spoofed URL (e.g., https://victim.com/) with an opaque origin, which is stored in the frame’s last_committed_url_ state.
When AddMatchingScriptsToProcess is later triggered, it queries frame->GetLastCommittedURL(), evaluates the spoofed URL against the extension’s content script patterns, and falsely associates the extension ID with the attacker’s renderer process.
Potential Security Impact
This vulnerability potentially permits a compromised renderer to bypass critical browser-side IPC validation checks. Specifically:
ScriptInjectionTracker::DidProcessRunContentScriptFromExtensionwill falsely returntruefor the attacker process and the target extension.- The attacker can bypass the check in
message_service_bindings.ccand successfully open message channels to the extension’s background page usingMessagingEndpoint::Type::kContentScript. - The attacker can bypass
extension_util::CanRendererActOnBehalfOfExtension, enabling the process to execute privileged extension APIs intended only for trusted content scripts.
Suggested/Potential Verification Steps
Note: The following are suggested verification steps; our analysis is based on static code tracing and we do not have the capability to execute code.
- From a compromised renderer, create a subframe and navigate to a URL that triggers a blocked request error (e.g., violating a CSP directive).
- Intercept the commit request and respond with a forged
DidCommitProvisionalLoadIPC settingparams.urlto the target victim domain (e.g.,https://victim.com/) andparams.originto a browser-supplied opaque origin. - Wait for an event that updates scripts (such as dynamic script registration via
chrome.scripting.registerContentScripts), triggeringAddMatchingScriptsToProcess. - Attempt to open a message channel via Mojo to the target extension with
source_endpoint.type = kContentScript. The browser-side validation should pass, allowing communication.
Proposed Fix
Modify AddMatchingScriptsToProcess in extensions/browser/script_injection_tracker.cc to explicitly skip frames that are error documents:
process.ForEachRenderFrameHost([&](content::RenderFrameHost* frame) {
if (frame->IsErrorDocument()) {
return;
}
const GURL& url = frame->GetLastCommittedURL();
...
Evaluated with Chrome root at commit: a2bea94528f4bd6cc57739c43fa3bb890b8367d3
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.