Medium chrome Memory Corruption 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Extensions
DescriptionMemory corruption in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassMemory Corruption
Tracker513486355
Fix commit1f283860b8db (chromium/src) +136/-61
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/api/declarative_content/content_action.cc
modified
is_declarative_
extensions/renderer/user_script_injector.cc
modified
script_type_
extensions/renderer/user_script_injector.cc
modified
TEST_F
chrome/browser/extensions/extension_action_runner_unittest.cc
modified

Files Changed

  • chrome/browser/extensions/extension_action_runner_unittest.cc
  • extensions/renderer/user_script_injector.cc
  • chrome/browser/extensions/api/declarative_content/content_action.cc
  • extensions/browser/script_injection_tracker.h
  • extensions/renderer/user_script_set.cc
  • extensions/browser/script_injection_tracker.cc
  • chrome/browser/extensions/extension_action_runner.cc
  • extensions/common/mojom/injection_type.mojom
  • extensions/renderer/user_script_injector.h
  • chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
From 1f283860b8dbf805bb5e8ed89e8825d6bcc8c30f Mon Sep 17 00:00:00 2001
From: Justin Lulejian <jlulejian@chromium.org>
Date: Wed, 30 Sep 2026 09:34:15 -0700
Subject: [PATCH] [Extensions] Fix ScriptInjectionTracker desync for RequestContentScript

Previously, RequestContentScript::InstructRenderProcessToInject() used
PermissionsData::CanAccessPage() to check if an extension could inject a
declarative content script into a frame. Because CanAccessPage() returns
true even when host permissions are withheld, the browser invoked
ScriptInjectionTracker::WillExecuteCode() prematurely before sending
ExecuteDeclarativeScript() to the renderer. In the renderer,
ScriptInjection::TryToInject() detected that permissions were withheld
and deferred execution. This caused the browser and renderer states to
desynchronize: the browser erroneously considered the renderer process
as having executed the extension's content script even though no script
ran.

With this change, the browser does not update ScriptInjectionTracker
when page access is withheld during declarative content script
evaluation. Instead, the renderer defers the script and requests
permission via ExtensionActionRunner. Once the user explicitly grants
permission on the tab, the deferred script executes in the renderer and
ScriptInjectionTracker is updated so browser and renderer states remain
synchronized.

We update RequestContentScript::InstructRenderProcessToInject() to query
PermissionsData::GetPageAccess() directly and only call
ScriptInjectionTracker::WillExecuteCode() when access is kAllowed. We
also add mojom::InjectionType::kDeclarativeScript, which
UserScriptInjector reports for RequestContentScript scripts, and have
ExtensionActionRunner gate it on PermissionsData::GetPageAccess() so
withheld requests are queued. ExtensionActionRunner already notifies
ScriptInjectionTracker before permitting pending scripts to execute.

Fixed: 513486355
Change-Id: Ic8a5e4b503f33759d75b440f782d9fde6a549fae
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8417402
Reviewed-by: Dominic Farolino <dom@chromium.org>
Reviewed-by: Tim <tjudkins@chromium.org>
Commit-Queue: Justin Lulejian <jlulejian@chromium.org>
Auto-Submit: Justin Lulejian <jlulejian@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1708194}
---
diff --git a/chrome/browser/extensions/api/declarative_content/content_action.cc b/chrome/browser/extensions/api/declarative_content/content_action.cc
index fed023b..2efa3c8 100644
--- a/chrome/browser/extensions/api/declarative_content/content_action.cc
+++ b/chrome/browser/extensions/api/declarative_content/content_action.cc
@@ -377,16 +377,20 @@
 void RequestContentScript::InstructRenderProcessToInject(
     content::WebContents* contents,
     const Extension* extension) const {
-  // Verify that the extension has permission to access the page before
-  // granting trust for script injection. This prevents a compromised renderer
-  // from fully bypassing permission checks (for example: a spoofed
-  // `extensions::mojom::LocalFrameHost::WatchedPageChange` IPC bypassing the
-  // check we have for an invalid selector and getting here).
+  // Bail out if the extension cannot access the page at all. This prevents a
+  // compromised renderer from fully bypassing permission checks (for example: a
+  // spoofed `extensions::mojom::LocalFrameHost::WatchedPageChange` IPC
+  // bypassing the check we have for an invalid selector and getting here).
+  // When access is withheld, the script is still sent to the renderer, which
+  // defers the injection and requests permission from the browser. Trust is
+  // only granted to the renderer process below when access is allowed.
   std::string error;
   content::RenderFrameHost* main_frame = contents->GetPrimaryMainFrame();
   const GURL& url = util::GetURLForExtensionPermissionCheck(main_frame);
-  if (!extension->permissions_data()->CanAccessPage(
-          url, ExtensionTabUtil::GetTabId(contents), &error)) {
+  PermissionsData::PageAccess page_access =
+      extension->permissions_data()->GetPageAccess(
+          url, ExtensionTabUtil::GetTabId(contents), &error);
+  if (page_access == PermissionsData::PageAccess::kDenied) {
     return;
   }
 
@@ -399,8 +403,13 @@
     return;
   }
 
-  ScriptInjectionTracker::WillExecuteCode(base::PassKey<RequestContentScript>(),
-                                          main_frame, *extension);
+  // When access is withheld, `ScriptInjectionTracker` is instead updated by
+  // `ExtensionActionRunner` if the user later grants access and the deferred
+  // script is permitted to run.
+  if (page_access == PermissionsData::PageAccess::kAllowed) {
+    ScriptInjectionTracker::WillExecuteCode(
+        base::PassKey<RequestContentScript>(), main_frame, *extension);
+  }
   local_frame->ExecuteDeclarativeScript(
       sessions::SessionTabHelper::IdForTab(contents).id(), extension->id(),
       script_.id(), url);
diff --git a/chrome/browser/extensions/extension_action_runner.cc b/chrome/browser/extensions/extension_action_runner.cc
index 2025f3a2..4773b5d4 100644
--- a/chrome/browser/extensions/extension_action_runner.cc
+++ b/chrome/browser/extensions/extension_action_runner.cc
@@ -275,6 +275,10 @@
       return extension->permissions_data()->GetContentScriptAccess(url, tab_id,
                                                                    nullptr);
     case mojom::InjectionType::kProgrammaticScript:
+    // Declarative scripts are gated on the extension's host permissions rather
+    // than its manifest content script matches, the same as
+    // `RequestContentScript::InstructRenderProcessToInject()`.
+    case mojom::InjectionType::kDeclarativeScript:
       return extension->permissions_data()->GetPageAccess(url, tab_id, nullptr);
   }
 
diff --git a/extensions/browser/script_injection_tracker.cc b/extensions/browser/script_injection_tracker.cc
index 0fa5fba..10c09d7b 100644
--- a/extensions/browser/script_injection_tracker.cc
+++ b/extensions/browser/script_injection_tracker.cc
@@ -850,9 +850,9 @@
               ChromeTrackEvent::kChromeExtensionId,
               ExtensionIdForTracing(extension.id()));
 
-  // A programmatic injection that was deferred while host access was withheld
-  // is being permitted to run; this is treated like any other programmatic
-  // content script injection.
+  // A programmatic or declarative injection that was deferred while host access
+  // was withheld is being permitted to run; this is treated like any other
+  // programmatic content script injection.
   HandleProgrammaticScriptInjection(PassKey(), ScriptType::kContentScript,
                                     frame, extension);
 }
diff --git a/extensions/browser/script_injection_tracker.h b/extensions/browser/script_injection_tracker.h
index 8f0ba4c..9066b254 100644
--- a/extensions/browser/script_injection_tracker.h
+++ b/extensions/browser/script_injection_tracker.h
@@ -135,9 +135,9 @@
                               content::RenderFrameHost* frame,
                               const Extension& extension);
 
-  // Called before a programmatic injection that was previously deferred
-  // (because host access was withheld) is permitted to run as a result of the
-  // user granting the extension access to the page.
+  // Called before a programmatic or declarative injection that was previously
+  // deferred (because host access was withheld) is permitted to run as a result
+  // of the user granting the extension access to the page.
   static void WillExecuteCode(base::PassKey<ExtensionActionRunner> pass_key,
                               mojom::InjectionType script_type,
                               content::RenderFrameHost* frame,
diff --git a/extensions/common/mojom/injection_type.mojom b/extensions/common/mojom/injection_type.mojom
index 1f83588..357ac61 100644
--- a/extensions/common/mojom/injection_type.mojom
+++ b/extensions/common/mojom/injection_type.mojom
@@ -10,4 +10,7 @@
   kContentScript,
   // A script injected via, e.g. tabs.executeScript().
   kProgrammaticScript,
+  // A script injected via the `RequestContentScript` action of the
+  // `chrome.declarativeContent` API.
+  kDeclarativeScript,
 };
diff --git a/extensions/renderer/user_script_injector.cc b/extensions/renderer/user_script_injector.cc
index 1e639bf..766e2529 100644
--- a/extensions/renderer/user_script_injector.cc
+++ b/extensions/renderer/user_script_injector.cc
@@ -122,12 +122,14 @@
 
 UserScriptInjector::UserScriptInjector(const UserScript* script,
                                        UserScriptSet* script_list,
-                                       bool is_declarative)
+                                       bool is_declarative,
+                                       mojom::InjectionType script_type)
     : script_(script),
       user_script_set_(script_list),
       script_id_(script_->id()),
       host_id_(script_->host_id()),
-      is_declarative_(is_declarative) {
+      is_declarative_(is_declarative),
+      script_type_(script_type) {
   user_script_set_observation_.Observe(script_list);
 }
 
@@ -149,7 +151,7 @@
 }
 
 mojom::InjectionType UserScriptInjector::script_type() const {
-  return mojom::InjectionType::kContentScript;
+  return script_type_;
 }
 
 blink::mojom::UserActivationOption UserScriptInjector::IsUserGesture() const {
diff --git a/extensions/renderer/user_script_injector.h b/extensions/renderer/user_script_injector.h
index 8a5205b..0c9c2e56 100644
--- a/extensions/renderer/user_script_injector.h
+++ b/extensions/renderer/user_script_injector.h
@@ -32,7 +32,8 @@
  public:
   UserScriptInjector(const UserScript* user_script,
                      UserScriptSet* user_script_set,
-                     bool is_declarative);
+                     bool is_declarative,
+                     mojom::InjectionType script_type);
 
   UserScriptInjector(const UserScriptInjector&) = delete;
   UserScriptInjector& operator=(const UserScriptInjector&) = delete;
@@ -94,9 +95,21 @@
   mojom::HostID host_id_;
 
   // Indicates whether or not this script is declarative. This influences which
-  // script permissions are checked before injection.
+  // script permissions are checked before injection. This is also true for
+  // extension dynamic scripts (e.g. from `scripting.registerContentScripts()`
+  // or the `userScripts` API), which use host permissions but are still
+  // reported to the browser as `mojom::InjectionType::kContentScript` via
+  // `script_type_`.
   bool is_declarative_;
 
+  // The injection type reported to the browser, e.g. when requesting
+  // permission to inject via
+  // `extensions::mojom::LocalFrameHost::RequestScriptInjectionPermission()`.
+  // Only scripts injected via the `RequestContentScript` action of the
+  // `chrome.declarativeContent` API are reported as
+  // `mojom::InjectionType::kDeclarativeScript`.
+  const mojom::InjectionType script_type_;
+
   base::ScopedObservation<UserScriptSet, UserScriptSet::Observer>
       user_script_set_observation_{this};
 };
diff --git a/extensions/renderer/user_script_set.cc b/extensions/renderer/user_script_set.cc
index fcec9315..fba35625 100644
--- a/extensions/renderer/user_script_set.cc
+++ b/extensions/renderer/user_script_set.cc
@@ -23,6 +23,7 @@
 #include "extensions/common/extension_features.h"
 #include "extensions/common/extensions_client.h"
 #include "extensions/common/mojom/host_id.mojom.h"
+#include "extensions/common/mojom/injection_type.mojom-shared.h"
 #include "extensions/renderer/extension_injection_host.h"
 #include "extensions/renderer/extensions_renderer_client.h"
 #include "extensions/renderer/injection_host.h"
@@ -287,8 +288,18 @@
       (host_id_.type == mojom::HostID::HostType::kExtensions) &&
       (script->GetSource() == UserScript::Source::kDynamicContentScript ||
        script->GetSource() == UserScript::Source::kDynamicUserScript);
+  // Only scripts injected via the `RequestContentScript` action of the
+  // `chrome.declarativeContent` API are reported as `kDeclarativeScript`.
+  // Extension dynamic scripts are still reported as `kContentScript` so that
+  // they keep content script behavior (e.g. asynchronous execution at
+  // `document_end` and `document_idle`).
+  mojom::InjectionType script_type =
+      is_declarative ? mojom::InjectionType::kDeclarativeScript
+                     : mojom::InjectionType::kContentScript;
   std::unique_ptr<ScriptInjector> injector(new UserScriptInjector(
-      script, this, is_declarative || is_extension_dynamic_script));
+      script, /*user_script_set=*/this,
+      /*is_declarative=*/is_declarative || is_extension_dynamic_script,
+      script_type));
 
   if (injector->CanExecuteOnFrame(injection_host.get(), web_frame, tab_id) ==
       PermissionsData::PageAccess::kDenied) {
diff --git a/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc b/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
index 806f57d..2c1b2f9 100644
--- a/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
+++ b/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
@@ -5,6 +5,8 @@
 #include "base/files/file_path.h"
 #include "base/memory/raw_ptr.h"
 #include "base/strings/stringprintf.h"
+#include "chrome/browser/extensions/blocked_action_waiter.h"
+#include "chrome/browser/extensions/extension_action_runner.h"
 #include "chrome/browser/extensions/extension_browsertest.h"
 #include "chrome/browser/extensions/extension_tab_util.h"
 #include "chrome/browser/profiles/profile.h"
@@ -242,32 +244,25 @@
 
 // Tests that when an extension's host permissions are withheld (e.g., site
 // access set to "On click"), triggering a declarative content rule with
-// `RequestContentScript` should not record script execution in
-// `ScriptInjectionTracker`.
+// `RequestContentScript` does not record script execution in
+// `ScriptInjectionTracker` until the user explicitly grants permission.
 //
-// The browser and renderer states should stay synchronized regarding whether
-// extension code has executed. Without proper gating, a bug causes these states
-// to desynchronize:
 // In the browser process,
 // `RequestContentScript::InstructRenderProcessToInject()` checks
-// `PermissionsData::CanAccessPage()`, which returns `true` even when host
-// permissions are withheld (`PermissionsData::PageAccess::kWithheld`). This
-// causes the browser to invoke `ScriptInjectionTracker::WillExecuteCode()`,
-// erroneously marking the renderer process as having executed the content
-// script.
-// In the renderer process,
-// `extensions::mojom::LocalFrame::ExecuteDeclarativeScript()` detects that host
-// permissions are withheld and defers injection without running any script.
+// `PermissionsData::GetPageAccess()`. When page access is
+// `PermissionsData::PageAccess::kWithheld`, the browser sends
+// `extensions::mojom::LocalFrame::ExecuteDeclarativeScript()` to the renderer
+// without calling `ScriptInjectionTracker::WillExecuteCode()`. In the renderer,
+// `ScriptInjection::TryToInject()` detects that permissions are withheld,
+// defers the injection, and requests permission from the browser via
+// `extensions::mojom::LocalFrameHost::RequestScriptInjectionPermission()`.
 //
-// As a result of this bug, the browser process considers the renderer
-// authorized to act on behalf of the extension, even though no extension code
-// ever executed in that renderer.
-//
-// Currently, this test documents the existing buggy behavior by expecting
-// `ScriptInjectionTracker::DidProcessRunContentScriptFromExtension()` to return
-// `true` so the test passes before the fix.
+// Once the user grants permission (simulated via
+// `ExtensionActionRunner::RunForTesting()`), the deferred content script
+// executes in the renderer and `ScriptInjectionTracker` records that the
+// process ran a content script from the extension.
 IN_PROC_BROWSER_TEST_F(RequestContentScriptAPITest,
-                       WithheldPermissionsPrematurelyUpdatesTracker) {
+                       WithheldPermissionsUpdateTrackerOnlyAfterGrant) {
   // Start the embedded test server to serve test pages.
   ASSERT_TRUE(embedded_test_server()->Start());
 
@@ -282,24 +277,31 @@
       .SetWithholdHostPermissions(/*withhold=*/true);
 
   // Set up a listener for script execution and navigate to a test URL on the
-  // embedded test server.
+  // embedded test server, waiting for the renderer to request permission for
+  // the deferred injection.
   content::WebContents* web_contents = GetActiveWebContents();
   ASSERT_TRUE(web_contents);
 
+  ExtensionActionRunner* action_runner =
+      ExtensionActionRunner::GetForWebContents(web_contents);
+  ASSERT_TRUE(action_runner);
+
   ExtensionTestMessageListener script_listener(kInjectionSucceeded);
   script_listener.set_extension_id(extension()->id());
 
   const GURL target_url =
       embedded_test_server()->GetURL("/extensions/test_file.html");
-  ASSERT_TRUE(NavigateToURL(web_contents, target_url));
+  {
+    BlockedActionWaiter blocked_action_waiter(action_runner);
+    ASSERT_TRUE(NavigateToURL(web_contents, target_url));
+    SCOPED_TRACE("Waiting for the deferred injection to request permission");
+    blocked_action_waiter.Wait();
+  }
 
   content::RenderProcessHost* target_process =
       web_contents->GetPrimaryMainFrame()->GetProcess();
   ASSERT_TRUE(target_process);
 
-  // Run pending tasks in renderer to allow any potential injection to complete.
-  ASSERT_TRUE(RunAllPendingInRenderer(web_contents));
-
   // Verify that the extension's page access is withheld on the target URL.
   EXPECT_EQ(PermissionsData::PageAccess::kWithheld,
             extension()->permissions_data()->GetPageAccess(
@@ -309,20 +311,21 @@
   // Verify that the content script was not executed in the renderer.
   EXPECT_FALSE(script_listener.was_satisfied());
 
-  // Verify that `ScriptInjectionTracker` records that the process ran a content
-  // script from this extension. This currently returns `true` because of the
-  // bug where `PermissionsData::CanAccessPage()` returns `true` for withheld
-  // permissions, erroneously notifying `ScriptInjectionTracker` before sending
-  // the injection message to the renderer.
-  //
-  // TODO(crbug.com/513486355): Once the bug is fixed in
-  // `RequestContentScript::InstructRenderProcessToInject()`, rename this test
-  // to `WithheldPermissionsDoNotUpdateTracker` and update this expectation to
-  // verify that `ScriptInjectionTracker` does not record execution when
-  // permissions are withheld:
-  // EXPECT_FALSE(
-  //     ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
-  //         *target_process, extension()->id()));
+  // Verify that `ScriptInjectionTracker` does not record that the process ran a
+  // content script from this extension while permissions are withheld.
+  EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+      *target_process, extension()->id()));
+
+  // Grant permission to run the blocked action on the active tab and verify
+  // that the deferred content script executes and updates the tracker.
+  ASSERT_TRUE(action_runner->WantsToRun(extension()));
+  action_runner->RunForTesting(extension());
+
+  {
+    SCOPED_TRACE("Waiting for content script to execute after granting access");
+    ASSERT_TRUE(script_listener.WaitUntilSatisfied());
+  }
+
   EXPECT_TRUE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
       *target_process, extension()->id()));
 }
diff --git a/chrome/browser/extensions/extension_action_runner_unittest.cc b/chrome/browser/extensions/extension_action_runner_unittest.cc
index ef5798d..5cf2eea 100644
--- a/chrome/browser/extensions/extension_action_runner_unittest.cc
+++ b/chrome/browser/extensions/extension_action_runner_unittest.cc
@@ -585,4 +585,34 @@
   EXPECT_FALSE(runner()->WantsToRun(extension));
 }
 
+// Tests that `ExtensionActionRunner::RequiresUserConsentForScriptInjection()`
+// gates `mojom::InjectionType::kDeclarativeScript` injections (from the
+// `RequestContentScript` action of the `chrome.declarativeContent` API) on the
+// extension's host permissions (`PermissionsData::GetPageAccess()`), the same
+// as `mojom::InjectionType::kProgrammaticScript`, rather than on its manifest
+// content script matches (`PermissionsData::GetContentScriptAccess()`).
+TEST_F(ExtensionActionRunnerUnitTest, DeclarativeScriptsUseHostPermissions) {
+  // Add an extension with withheld all hosts permissions and no manifest
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc b/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
index 806f57d..2c1b2f9 100644
--- a/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
+++ b/chrome/browser/extensions/api/declarative_content/request_content_script_apitest.cc
@@ -5,6 +5,8 @@
 #include "base/files/file_path.h"
 #include "base/memory/raw_ptr.h"
 #include "base/strings/stringprintf.h"
+#include "chrome/browser/extensions/blocked_action_waiter.h"
+#include "chrome/browser/extensions/extension_action_runner.h"
 #include "chrome/browser/extensions/extension_browsertest.h"
 #include "chrome/browser/extensions/extension_tab_util.h"
 #include "chrome/browser/profiles/profile.h"
@@ -242,32 +244,25 @@
 
 // Tests that when an extension's host permissions are withheld (e.g., site
 // access set to "On click"), triggering a declarative content rule with
-// `RequestContentScript` should not record script execution in
-// `ScriptInjectionTracker`.
+// `RequestContentScript` does not record script execution in
+// `ScriptInjectionTracker` until the user explicitly grants permission.
 //
-// The browser and renderer states should stay synchronized regarding whether
-// extension code has executed. Without proper gating, a bug causes these states
-// to desynchronize:
 // In the browser process,
 // `RequestContentScript::InstructRenderProcessToInject()` checks
-// `PermissionsData::CanAccessPage()`, which returns `true` even when host
-// permissions are withheld (`PermissionsData::PageAccess::kWithheld`). This
-// causes the browser to invoke `ScriptInjectionTracker::WillExecuteCode()`,
-// erroneously marking the renderer process as having executed the content
-// script.
-// In the renderer process,
-// `extensions::mojom::LocalFrame::ExecuteDeclarativeScript()` detects that host
-// permissions are withheld and defers injection without running any script.
+// `PermissionsData::GetPageAccess()`. When page access is
+// `PermissionsData::PageAccess::kWithheld`, the browser sends
+// `extensions::mojom::LocalFrame::ExecuteDeclarativeScript()` to the renderer
+// without calling `ScriptInjectionTracker::WillExecuteCode()`. In the renderer,
+// `ScriptInjection::TryToInject()` detects that permissions are withheld,
+// defers the injection, and requests permission from the browser via
+// `extensions::mojom::LocalFrameHost::RequestScriptInjectionPermission()`.
 //
-// As a result of this bug, the browser process considers the renderer
-// authorized to act on behalf of the extension, even though no extension code
-// ever executed in that renderer.
-//
-// Currently, this test documents the existing buggy behavior by expecting
-// `ScriptInjectionTracker::DidProcessRunContentScriptFromExtension()` to return
-// `true` so the test passes before the fix.
+// Once the user grants permission (simulated via
+// `ExtensionActionRunner::RunForTesting()`), the deferred content script
+// executes in the renderer and `ScriptInjectionTracker` records that the
+// process ran a content script from the extension.
 IN_PROC_BROWSER_TEST_F(RequestContentScriptAPITest,
-                       WithheldPermissionsPrematurelyUpdatesTracker) {
+                       WithheldPermissionsUpdateTrackerOnlyAfterGrant) {
   // Start the embedded test server to serve test pages.
   ASSERT_TRUE(embedded_test_server()->Start());
 
@@ -282,24 +277,31 @@
       .SetWithholdHostPermissions(/*withhold=*/true);
 
   // Set up a listener for script execution and navigate to a test URL on the
-  // embedded test server.
+  // embedded test server, waiting for the renderer to request permission for
+  // the deferred injection.
   content::WebContents* web_contents = GetActiveWebContents();
   ASSERT_TRUE(web_contents);
 
+  ExtensionActionRunner* action_runner =
+      ExtensionActionRunner::GetForWebContents(web_contents);
+  ASSERT_TRUE(action_runner);
+
   ExtensionTestMessageListener script_listener(kInjectionSucceeded);
   script_listener.set_extension_id(extension()->id());
 
   const GURL target_url =
       embedded_test_server()->GetURL("/extensions/test_file.html");
-  ASSERT_TRUE(NavigateToURL(web_contents, target_url));
+  {
+    BlockedActionWaiter blocked_action_waiter(action_runner);
+    ASSERT_TRUE(NavigateToURL(web_contents, target_url));
+    SCOPED_TRACE("Waiting for the deferred injection to request permission");
+    blocked_action_waiter.Wait();
+  }
 
   content::RenderProcessHost* target_process =
       web_contents->GetPrimaryMainFrame()->GetProcess();
   ASSERT_TRUE(target_process);
 
-  // Run pending tasks in renderer to allow any potential injection to complete.
-  ASSERT_TRUE(RunAllPendingInRenderer(web_contents));
-
   // Verify that the extension's page access is withheld on the target URL.
   EXPECT_EQ(PermissionsData::PageAccess::kWithheld,
             extension()->permissions_data()->GetPageAccess(
@@ -309,20 +311,21 @@
   // Verify that the content script was not executed in the renderer.
   EXPECT_FALSE(script_listener.was_satisfied());
 
-  // Verify that `ScriptInjectionTracker` records that the process ran a content
-  // script from this extension. This currently returns `true` because of the
-  // bug where `PermissionsData::CanAccessPage()` returns `true` for withheld
-  // permissions, erroneously notifying `ScriptInjectionTracker` before sending
-  // the injection message to the renderer.
-  //
-  // TODO(crbug.com/513486355): Once the bug is fixed in
-  // `RequestContentScript::InstructRenderProcessToInject()`, rename this test
-  // to `WithheldPermissionsDoNotUpdateTracker` and update this expectation to
-  // verify that `ScriptInjectionTracker` does not record execution when
-  // permissions are withheld:
-  // EXPECT_FALSE(
-  //     ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
-  //         *target_process, extension()->id()));
+  // Verify that `ScriptInjectionTracker` does not record that the process ran a
+  // content script from this extension while permissions are withheld.
+  EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+      *target_process, extension()->id()));
+
+  // Grant permission to run the blocked action on the active tab and verify
+  // that the deferred content script executes and updates the tracker.
+  ASSERT_TRUE(action_runner->WantsToRun(extension()));
+  action_runner->RunForTesting(extension());
+
+  {
+    SCOPED_TRACE("Waiting for content script to execute after granting access");
+    ASSERT_TRUE(script_listener.WaitUntilSatisfied());
+  }
+
   EXPECT_TRUE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
       *target_process, extension()->id()));
 }
diff --git a/chrome/browser/extensions/extension_action_runner_unittest.cc b/chrome/browser/extensions/extension_action_runner_unittest.cc
index ef5798d..5cf2eea 100644
--- a/chrome/browser/extensions/extension_action_runner_unittest.cc
+++ b/chrome/browser/extensions/extension_action_runner_unittest.cc
@@ -585,4 +585,34 @@
   EXPECT_FALSE(runner()->WantsToRun(extension));
 }
 
+// Tests that `ExtensionActionRunner::RequiresUserConsentForScriptInjection()`
+// gates `mojom::InjectionType::kDeclarativeScript` injections (from the
+// `RequestContentScript` action of the `chrome.declarativeContent` API) on the
+// extension's host permissions (`PermissionsData::GetPageAccess()`), the same
+// as `mojom::InjectionType::kProgrammaticScript`, rather than on its manifest
+// content script matches (`PermissionsData::GetContentScriptAccess()`).
+TEST_F(ExtensionActionRunnerUnitTest, DeclarativeScriptsUseHostPermissions) {
+  // Add an extension with withheld all hosts permissions and no manifest
+  // content scripts, then navigate to a page it has requested host permissions
+  // for.
+  const Extension* extension = AddExtension();
+  ASSERT_TRUE(extension);
+  NavigateAndCommit(GURL("https://www.google.com"));
+
+  // Content script injections are denied since the extension has no manifest
+  // content scripts matching the page.
+  EXPECT_EQ(PermissionsData::PageAccess::kDenied,
+            runner()->RequiresUserConsentForScriptInjectionForTesting(
+                extension, mojom::InjectionType::kContentScript));
+
+  // Declarative and programmatic script injections are withheld because they
+  // use the extension's withheld host permissions.
+  EXPECT_EQ(PermissionsData::PageAccess::kWithheld,
+            runner()->RequiresUserConsentForScriptInjectionForTesting(
+                extension, mojom::InjectionType::kDeclarativeScript));
+  EXPECT_EQ(PermissionsData::PageAccess::kWithheld,
+            runner()->RequiresUserConsentForScriptInjectionForTesting(
+                extension, mojom::InjectionType::kProgrammaticScript));
+}
+
 }  // namespace extensions
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.