Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient data validation in Extensions
DescriptionInsufficient data validation in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker517153117
Fix commit2e4412b46328 (chromium/src) +74/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-16

Files Changed

  • chrome/browser/extensions/script_injection_tracker_browsertest.cc
  • extensions/browser/script_executor.cc
From 2e4412b4632880203755104d93be611b12b3740a Mon Sep 17 00:00:00 2001
From: Andrea Orru <[email protected]>
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 <[email protected]>
Reviewed-by: Devlin Cronin <[email protected]>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.

  1. Preconditions:

    • An attacker controls a third-party origin https://thirdparty.example running in a site-isolated renderer process $P_{attacker}$.
    • A benign extension with host permissions for https://target.example/* is installed and programmatically executes scripts with allFrames: true (e.g., in response to a tab update).
  2. Triggering the Vulnerability:

    • The user navigates to https://target.example/page (process $P_{target}$), which embeds an iframe pointing to https://thirdparty.example/widget ($P_{attacker}$).
    • The attacker-controlled page at https://thirdparty.example/widget is served with the HTTP header Content-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_CSP and 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). Its GetLastCommittedURL() returns https://target.example/x, and IsErrorDocument() is true.
  3. Exploiting the State:

    • The benign extension invokes executeScript({allFrames: true}). ScriptExecutor::Handler walks the frames.
    • It visits the grandchild frame. Since IsErrorDocument() is not checked in MaybeAddSubFrame(), and GetLastCommittedURL() is https://target.example/x, HasPermissionToInjectIntoFrame() returns true.
    • The frame is added to pending_render_frames_, which triggers ScriptInjectionTracker::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() or IsValidMessagingSource(). This allows the attacker to escalate privileges to access extension storage (via chrome.storage.local) or dispatch internal messages to the extension’s background page/service worker.

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.

View on issue tracker