High watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Extensions
DescriptionIncorrect authorization in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker535876894
Fix commitac5658b6d7bb (chromium/src) +221/-16
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
Credited章鱼哥@aipyaipy.com
Reported2026-07-17 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-08-25
Patch gap6 days — fix public in source before the advisory

Background

Error document
The page committed at chrome-error://chromewebdata/ when a navigation fails, whose RenderFrameHost::GetLastCommittedURL() still returns the failed destination target URL rather than the error URL.
`GetLastCommittedURL()`
The content::RenderFrameHost/content::WebContents accessor for the last committed document URL, which extension code historically consulted to decide permissions and content-script identity.
`BrowserFrameContextData`
The extensions-layer adapter that surfaces a frame’s URL and origin (GetUrl()) to the extension feature-availability system.
`externally_connectable`
A manifest capability letting listed web origins open message ports to an extension, matched against the source frame’s URL.

Root Cause Analysis

Several extension permission, messaging, and capability checks read GetLastCommittedURL() on a RenderFrameHost or WebContents without first checking whether the frame was displaying an error document. Because an error page’s GetLastCommittedURL() returns the failed destination URL, a frame that failed to navigate to https://victim.example/ would report that victim URL for permission purposes even though it is actually hosting the error document at chrome-error://chromewebdata/. A compromised renderer hosting such an error page could therefore claim the victim URL’s identity for CanAccessPage() checks, externally_connectable matching, activeTab grants, and declarativeContent script-injection identity.

The fix introduces extensions::util::GetURLForExtensionPermissionCheck(RenderFrameHost*), which returns an empty GURL when the frame is null, has an empty committed URL, or IsErrorDocument() is true, and migrates the vulnerable call sites (and BrowserFrameContextData::GetUrl()) to use it, so error documents can no longer spoof the failed target’s URL.

Key insight
The single mistake was trusting GetLastCommittedURL() as a document’s true origin/URL for authorization when, on error pages, it deliberately returns the failed navigation target; the fix centralizes an error-document-aware helper that yields an empty GURL so no permission is granted to the spoofed URL.

Attack Path

  1. Compromise a renderer An attacker with a compromised renderer process (or one able to drive a frame into an error state for a chosen target) controls IPC that extension checks act upon.
  2. Commit an error document for a target URL Trigger a navigation to https://victim.example/ that fails and commits chrome-error://chromewebdata/, so GetLastCommittedURL() reports https://victim.example/.
  3. Invoke a spoofable check Cause an extension permission/messaging path (e.g. externally_connectable matching in MessageService, CanAccessPage() in RequestContentScript::Apply, or webRequest embedder availability) to read the frame URL.
  4. Obtain the spoofed grant The check treats the frame as the victim URL, so the renderer connects to an externally_connectable extension, receives an activeTab/script grant, or spoofs declarativeContent injection identity for the victim origin.

Impact Assessment

An attacker gains incorrect authorization in the Extensions component: an error-page frame can claim permissions, message externally_connectable extensions, or spoof content-script injection identity for a URL it does not actually host, from a compromised renderer context. The advisory classifies this as incorrect authorization in Extensions; no exploited-in-the-wild status is stated. Any escalation to extension-privilege abuse beyond the spoofed grant is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/api/declarative_content/content_action.cc
modified
TEST_F
extensions/browser/extension_util_unittest.cc
modified

Files Changed

  • content/public/browser/render_frame_host.h
  • extensions/browser/browser_frame_context_data.cc
  • chrome/browser/extensions/api/declarative_content/content_action.cc
  • extensions/browser/api/messaging/message_service.cc
  • extensions/browser/extension_util.h
  • extensions/browser/extension_util_unittest.cc
  • content/public/browser/web_contents.h
  • extensions/browser/api/web_request/web_request_api.cc
  • extensions/browser/extension_util.cc
  • extensions/browser/browser_frame_context_data_unittest.cc

Audit Directions

  • Error-document URL trust
    Search for GetLastCommittedURL() used in security/authorization/permission decisions without an accompanying IsErrorDocument() check, especially across content::WebContents- and RenderFrameHost-level accessors.
  • Failed-navigation identity spoofing
    Review any subsystem that keys capabilities on a frame’s committed URL/origin to confirm error pages and uncommitted/speculative frames yield an empty or safe value.
  • Centralize vs. re-check
    Audit remaining extension and non-extension embedders that still call raw URL getters to ensure they route through an error-aware helper rather than each re-implementing the IsErrorDocument() guard.
From ac5658b6d7bb7ca9417f636dc83b09d226a0a2ec Mon Sep 17 00:00:00 2001
From: Justin Lulejian <[email protected]>
Date: Wed, 19 Aug 2026 11:58:25 -0700
Subject: [PATCH] [Extensions] Disallow error documents from spoofing permissions

When a navigation fails and commits an error page
(chrome-error://chromewebdata/), RenderFrameHost::GetLastCommittedURL()
returns the failed destination target URL rather than the error page
URL. Previously, several extension permission, messaging, and
capability checks inspected GetLastCommittedURL() on RenderFrameHost or
WebContents without checking whether the frame was displaying an error
document. This allowed compromised renderers hosting an error page to
spoof permissions or content-script identity for the failed destination
target URL.

With this change, error documents are explicitly filtered out during
extension permission and access checks, returning an empty GURL instead
of the failed destination target URL. Error pages can no longer claim
permissions, receive activeTab grants, connect to externally_connectable
extensions, or spoof declarativeContent script injection identities.

To accomplish this, we:
1. Introduced util::GetURLForExtensionPermissionCheck(RenderFrameHost*)
   in extension_util.h/.cc which returns an empty GURL if the frame is
   null, uncommitted, or displaying an error document.
2. Updated BrowserFrameContextData::GetUrl() to delegate to
   GetURLForExtensionPermissionCheck().
3. Migrated permission check call sites across declarativeContent,
   messaging, activeTab, host access requests, site permissions helper,
   and webRequest embedders.
4. Added comprehensive unit tests in extension_util_unittest.cc and
   browser_frame_context_data_unittest.cc.

TAG=agy
CONV=2b4b4118-cfbd-484a-abc2-16a733f5218e

Fixed: 540870921, 535876894
Change-Id: Ia47199c56c8ae9855e45e0c545fde141573bf64b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8246242
Reviewed-by: Dave Tapuska <[email protected]>
Auto-Submit: Justin Lulejian <[email protected]>
Reviewed-by: Andrea Orru <[email protected]>
Commit-Queue: Justin Lulejian <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1682524}
---
diff --git a/chrome/browser/extensions/api/declarative_content/content_action.cc b/chrome/browser/extensions/api/declarative_content/content_action.cc
index 85cd204..fed023b 100644
--- a/chrome/browser/extensions/api/declarative_content/content_action.cc
+++ b/chrome/browser/extensions/api/declarative_content/content_action.cc
@@ -18,11 +18,13 @@
 #include "chrome/browser/profiles/profile.h"
 #include "components/sessions/content/session_tab_helper.h"
 #include "content/public/browser/invalidate_type.h"
+#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents.h"
 #include "extensions/browser/extension_action.h"
 #include "extensions/browser/extension_action_manager.h"
 #include "extensions/browser/extension_system.h"
 #include "extensions/browser/extension_user_script_loader.h"
+#include "extensions/browser/extension_util.h"
 #include "extensions/browser/extension_web_contents_observer.h"
 #include "extensions/browser/icon_util.h"
 #include "extensions/browser/script_injection_tracker.h"
@@ -381,27 +383,27 @@
   // `extensions::mojom::LocalFrameHost::WatchedPageChange` IPC bypassing the
   // check we have for an invalid selector and getting here).
   std::string error;
+  content::RenderFrameHost* main_frame = contents->GetPrimaryMainFrame();
+  const GURL& url = util::GetURLForExtensionPermissionCheck(main_frame);
   if (!extension->permissions_data()->CanAccessPage(
-          contents->GetLastCommittedURL(), ExtensionTabUtil::GetTabId(contents),
-          &error)) {
+          url, ExtensionTabUtil::GetTabId(contents), &error)) {
     return;
   }
 
-  ScriptInjectionTracker::WillExecuteCode(base::PassKey<RequestContentScript>(),
-                                          contents->GetPrimaryMainFrame(),
-                                          *extension);
-
   mojom::LocalFrame* local_frame =
       ExtensionWebContentsObserver::GetForWebContents(contents)->GetLocalFrame(
-          contents->GetPrimaryMainFrame());
+          main_frame);
   if (!local_frame) {
     // TODO(crbug.com/40763607): Need to review when this method is
     // called with non-live frame.
     return;
   }
+
+  ScriptInjectionTracker::WillExecuteCode(base::PassKey<RequestContentScript>(),
+                                          main_frame, *extension);
   local_frame->ExecuteDeclarativeScript(
       sessions::SessionTabHelper::IdForTab(contents).id(), extension->id(),
-      script_.id(), contents->GetLastCommittedURL());
+      script_.id(), url);
 }
 
 void RequestContentScript::OnScriptsLoaded(
diff --git a/content/public/browser/render_frame_host.h b/content/public/browser/render_frame_host.h
index ad3ee33..112c8c3 100644
--- a/content/public/browser/render_frame_host.h
+++ b/content/public/browser/render_frame_host.h
@@ -539,6 +539,15 @@
   // Note that this does not reflect navigations in other RenderFrameHosts,
   // frames, or pages within the same WebContents, so it may differ from
   // NavigationController::GetLastCommittedEntry().
+  //
+  // Note: When a navigation fails and commits an error page (e.g.
+  // `chrome-error://chromewebdata/`), `GetLastCommittedURL()` continues to
+  // return the failed destination target URL rather than an error URL.
+  // Therefore, this should not be used directly for security, authorization,
+  // or permission checks without verifying `!IsErrorDocument()`.
+  // Higher-level layers (such as extensions) should use their dedicated
+  // permission-check URL helper (e.g.,
+  // `extensions::util::GetURLForExtensionPermissionCheck()`).
   virtual const GURL& GetLastCommittedURL() const = 0;
 
   // Returns the last committed origin of this RenderFrameHost.
diff --git a/content/public/browser/web_contents.h b/content/public/browser/web_contents.h
index 160831c..75dede1 100644
--- a/content/public/browser/web_contents.h
+++ b/content/public/browser/web_contents.h
@@ -528,6 +528,16 @@
   // See also GetVisibleURL above, which may differ from this URL. Note that
   // this might return an empty GURL if no navigation has committed in the
   // WebContents' main frame.
+  //
+  // Note: When a navigation fails and commits an error page (e.g.
+  // `chrome-error://chromewebdata/`), `GetLastCommittedURL()` continues to
+  // return the failed destination target URL rather than an error URL.
+  // Therefore, this should not be used directly for security, authorization,
+  // or permission checks without verifying that the primary main frame is not
+  // an error document (`!GetPrimaryMainFrame()->IsErrorDocument()`).
+  // Higher-level layers (such as extensions) should use their dedicated
+  // permission-check URL helper (e.g.,
+  // `extensions::util::GetURLForExtensionPermissionCheck()`).
   virtual const GURL& GetLastCommittedURL() const = 0;
 
   // Returns the primary main frame for the currently active page. Always
diff --git a/extensions/browser/api/messaging/message_service.cc b/extensions/browser/api/messaging/message_service.cc
index 429f260..47de7d3 100644
--- a/extensions/browser/api/messaging/message_service.cc
+++ b/extensions/browser/api/messaging/message_service.cc
@@ -545,10 +545,8 @@
         // Check that the web page URL matches. Skip error pages, whose last
         // committed URL reflects the failed navigation target rather than a
         // document the source process actually hosts.
-        is_externally_connectable =
-            !source_render_frame_host->IsErrorDocument() &&
-            externally_connectable->matches.MatchesURL(
-                source_render_frame_host->GetLastCommittedURL());
+        is_externally_connectable = externally_connectable->matches.MatchesURL(
+            util::GetURLForExtensionPermissionCheck(source_render_frame_host));
       }
     } else {
       // Default behaviour. Any extension or content script, no webpages.
diff --git a/extensions/browser/api/web_request/web_request_api.cc b/extensions/browser/api/web_request/web_request_api.cc
index 1b7c3ffa..42a3fab 100644
--- a/extensions/browser/api/web_request/web_request_api.cc
+++ b/extensions/browser/api/web_request/web_request_api.cc
@@ -1059,7 +1059,8 @@
       render_frame_host->GetBrowserContext();
   content::RenderFrameHost* embedder_frame =
       render_frame_host->GetOutermostMainFrameOrEmbedder();
-  const auto& embedder_url = embedder_frame->GetLastCommittedURL();
+  const GURL& embedder_url =
+      util::GetURLForExtensionPermissionCheck(embedder_frame);
   // TODO(crbug.com/40288053): Remove the scheme check once we're sure
   // that WebUIs with WebView run in real WebUI processes and check the
   // context type using |IsAvailableToWebViewEmbedderWebPageFrame()| below.
@@ -1120,7 +1121,8 @@
   Feature::Availability availability =
       ExtensionAPI::GetSharedInstance()->IsAvailable(
           "webRequestInternal", /*extension=*/nullptr,
-          mojom::ContextType::kWebPage, embedder_frame->GetLastCommittedURL(),
+          mojom::ContextType::kWebPage,
+          util::GetURLForExtensionPermissionCheck(embedder_frame),
           CheckAliasStatus::ALLOWED, util::GetBrowserContextId(browser_context),
           BrowserFrameContextData(embedder_frame));
   return availability.is_available();
diff --git a/extensions/browser/browser_frame_context_data.cc b/extensions/browser/browser_frame_context_data.cc
index 3260341c..a3fc381 100644
--- a/extensions/browser/browser_frame_context_data.cc
+++ b/extensions/browser/browser_frame_context_data.cc
@@ -10,6 +10,7 @@
 #include "content/public/browser/isolated_web_apps_policy.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents.h"
+#include "extensions/browser/extension_util.h"
 #include "extensions/browser/extensions_browser_client.h"
 #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
 
@@ -66,7 +67,7 @@
     // document's URL about:blank instead of empty in that case.
     return GURL(url::kAboutBlankURL);
   }
-  return frame_->GetLastCommittedURL();
+  return util::GetURLForExtensionPermissionCheck(frame_);
 }
 
 url::Origin BrowserFrameContextData::GetOrigin() const {
diff --git a/extensions/browser/extension_util.cc b/extensions/browser/extension_util.cc
index 88d615a..d934b87 100644
--- a/extensions/browser/extension_util.cc
+++ b/extensions/browser/extension_util.cc
@@ -16,6 +16,7 @@
 #include "components/user_prefs/user_prefs.h"
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/child_process_security_policy.h"
+#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/security_principal.h"
 #include "content/public/browser/site_instance.h"
 #include "content/public/browser/storage_partition.h"
@@ -587,4 +588,17 @@
   return false;
 }
 
+const GURL& GetURLForExtensionPermissionCheck(content::RenderFrameHost* rfh) {
+  // Avoid `CHECK_NE(lifecycle_state(), LifecycleStateImpl::kSpeculative)` in
+  // `content::RenderFrameHost::IsErrorDocument()` by checking for an empty
+  // `GURL` first.
+  if (!rfh || rfh->GetLastCommittedURL().is_empty()) {
+    return GURL::EmptyGURL();
+  }
+  if (rfh->IsErrorDocument()) {
+    return GURL::EmptyGURL();
+  }
+  return rfh->GetLastCommittedURL();
+}
+
 }  // namespace extensions::util
diff --git a/extensions/browser/extension_util.h b/extensions/browser/extension_util.h
index e058d52..c3bf25f 100644
--- a/extensions/browser/extension_util.h
+++ b/extensions/browser/extension_util.h
@@ -8,7 +8,6 @@
 #include <string>
 #include <vector>
 
-#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/common/child_process_id.h"
 #include "extensions/buildflags/buildflags.h"
@@ -185,6 +184,16 @@
 // scripts to be extension downloads, since we convert those automatically.
 bool IsExtensionDownload(const download::DownloadItem& download_item);
 
+// Returns the last committed `GURL` of `rfh` for extension permission and
+// authorization checks. Returns an empty `GURL` if:
+// - `rfh` is null,
+// - `rfh` has an empty committed `GURL`, or
+// - `rfh` is displaying an error document.
+//
+// For tab-level or `content::WebContents`-level checks, pass the primary main
+// frame (e.g. `web_contents->GetPrimaryMainFrame()`).
+const GURL& GetURLForExtensionPermissionCheck(content::RenderFrameHost* rfh);
+
 }  // namespace util
 }  // namespace extensions
 
diff --git a/extensions/browser/browser_frame_context_data_unittest.cc b/extensions/browser/browser_frame_context_data_unittest.cc
index cd2b4ef..b13c930f 100644
--- a/extensions/browser/browser_frame_context_data_unittest.cc
+++ b/extensions/browser/browser_frame_context_data_unittest.cc
@@ -5,11 +5,16 @@
 #include "extensions/browser/browser_frame_context_data.h"
 
 #include <memory>
+
 #include "base/memory/scoped_refptr.h"
+#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/site_instance.h"
 #include "content/public/browser/web_contents.h"
+#include "content/public/test/navigation_simulator.h"
 #include "content/public/test/web_contents_tester.h"
 #include "extensions/browser/extensions_test.h"
+#include "net/base/net_errors.h"
 #include "url/origin.h"
 #include "url/url_constants.h"
 
@@ -134,4 +139,58 @@
   }
 }
 
+// Verifies that `BrowserFrameContextData::GetUrl()`:
+// - Returns the committed `GURL` for valid committed documents.
+// - Returns an empty `GURL` for error documents.
+// - Returns `about:blank` for uncommitted initial frames.
+// - Safely inspects frames in
+//   `content::RenderFrameHost::LifecycleState::kPendingCommit` without
+//   triggering frame lifecycle check failure assertions.
+TEST_F(BrowserFrameContextDataTest,
+       GetUrl_ErrorDocumentsAndPendingNavigations) {
+  // Verify that an error document returns an empty `GURL` instead of the
+  // failed destination target URL.
+  {
+    auto site_instance = content::SiteInstance::Create(browser_context());
+    auto web_contents = content::WebContentsTester::CreateTestWebContents(
+        browser_context(), site_instance);
+    const GURL kFailedUrl("https://example.com/error.html");
+    content::NavigationSimulator::NavigateAndFailFromBrowser(
+        web_contents.get(), kFailedUrl, net::ERR_FAILED);
+
+    auto* rfh = GetRenderFrameHost(web_contents.get());
+    ASSERT_TRUE(rfh->IsErrorDocument());
+
+    BrowserFrameContextData data(rfh);
+    EXPECT_TRUE(data.GetUrl().is_empty());
+  }
+
+  // Simulate an uncommitted cross-site navigation reaching `ReadyToCommit` to
+  // verify that `BrowserFrameContextData::GetUrl()` safely handles a frame in
+  // `content::RenderFrameHost::LifecycleState::kPendingCommit` without
+  // triggering assertion crashes.
+  {
+    auto site_instance = content::SiteInstance::Create(browser_context());
+    auto web_contents = content::WebContentsTester::CreateTestWebContents(
+        browser_context(), site_instance);
+    const GURL kInitialUrl("https://example.com/initial.html");
+    content::NavigationSimulator::NavigateAndCommitFromBrowser(
+        web_contents.get(), kInitialUrl);
+
+    const GURL kCrossSiteUrl("https://other-site.com/pending.html");
+    auto navigation = content::NavigationSimulator::CreateBrowserInitiated(
+        kCrossSiteUrl, web_contents.get());
+    navigation->ReadyToCommit();
+
+    content::RenderFrameHost* pending_rfh =
+        navigation->GetNavigationHandle()->GetRenderFrameHost();
+    ASSERT_TRUE(pending_rfh);
+    EXPECT_TRUE(pending_rfh->IsInLifecycleState(
+        content::RenderFrameHost::LifecycleState::kPendingCommit));
+
+    BrowserFrameContextData data(pending_rfh);
+    EXPECT_EQ(data.GetUrl(), GURL(url::kAboutBlankURL));
+  }
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/extension_util_unittest.cc b/extensions/browser/extension_util_unittest.cc
index c31bd0e6..5474520 100644
--- a/extensions/browser/extension_util_unittest.cc
+++ b/extensions/browser/extension_util_unittest.cc
@@ -8,14 +8,21 @@
 #include "base/path_service.h"
 #include "base/strings/strcat.h"
 #include "build/android_buildflags.h"
+#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/site_instance.h"
+#include "content/public/browser/web_contents.h"
 #include "content/public/common/url_constants.h"
 #include "content/public/test/browser_task_environment.h"
+#include "content/public/test/navigation_simulator.h"
 #include "content/public/test/test_browser_context.h"
 #include "content/public/test/test_content_client_initializer.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/public/test/web_contents_tester.h"
+#include "extensions/browser/extensions_test.h"
 #include "extensions/common/extension_builder.h"
 #include "extensions/common/extension_paths.h"
 #include "extensions/common/extension_set.h"
+#include "net/base/net_errors.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "url/url_constants.h"
 
@@ -103,4 +110,98 @@
   EXPECT_EQ("", util::GetExtensionIdForSiteInstance(*https_site_instance));
 }
 
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns an empty
+// `GURL` when passed a null `content::RenderFrameHost*`.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_Null) {
+  content::RenderFrameHost* null_rfh = nullptr;
+  EXPECT_TRUE(util::GetURLForExtensionPermissionCheck(null_rfh).is_empty());
+}
+
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns an empty
+// `GURL` when passed an uncommitted initial frame.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_Uncommitted) {
+  std::unique_ptr<content::WebContents> web_contents =
+      content::WebContentsTester::CreateTestWebContents(browser_context(),
+                                                        /*instance=*/nullptr);
+  content::RenderFrameHost* main_rfh = web_contents->GetPrimaryMainFrame();
+  EXPECT_TRUE(util::GetURLForExtensionPermissionCheck(main_rfh).is_empty());
+}
+
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns the committed
+// `GURL` for a valid main frame navigation, and returns an empty `GURL` when
+// the main frame fails to navigate and commits an error document.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_MainFrame) {
+  // Create a test `content::WebContents`.
+  std::unique_ptr<content::WebContents> web_contents =
+      content::WebContentsTester::CreateTestWebContents(browser_context(),
+                                                        /*instance=*/nullptr);
+  content::RenderFrameHost* main_rfh = web_contents->GetPrimaryMainFrame();
+
+  // Simulate a successful navigation to a valid webpage in the main frame.
+  const GURL kValidUrl("https://example.com/page.html");
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/extensions/browser/browser_frame_context_data_unittest.cc b/extensions/browser/browser_frame_context_data_unittest.cc
index cd2b4ef..b13c930f 100644
--- a/extensions/browser/browser_frame_context_data_unittest.cc
+++ b/extensions/browser/browser_frame_context_data_unittest.cc
@@ -5,11 +5,16 @@
 #include "extensions/browser/browser_frame_context_data.h"
 
 #include <memory>
+
 #include "base/memory/scoped_refptr.h"
+#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/site_instance.h"
 #include "content/public/browser/web_contents.h"
+#include "content/public/test/navigation_simulator.h"
 #include "content/public/test/web_contents_tester.h"
 #include "extensions/browser/extensions_test.h"
+#include "net/base/net_errors.h"
 #include "url/origin.h"
 #include "url/url_constants.h"
 
@@ -134,4 +139,58 @@
   }
 }
 
+// Verifies that `BrowserFrameContextData::GetUrl()`:
+// - Returns the committed `GURL` for valid committed documents.
+// - Returns an empty `GURL` for error documents.
+// - Returns `about:blank` for uncommitted initial frames.
+// - Safely inspects frames in
+//   `content::RenderFrameHost::LifecycleState::kPendingCommit` without
+//   triggering frame lifecycle check failure assertions.
+TEST_F(BrowserFrameContextDataTest,
+       GetUrl_ErrorDocumentsAndPendingNavigations) {
+  // Verify that an error document returns an empty `GURL` instead of the
+  // failed destination target URL.
+  {
+    auto site_instance = content::SiteInstance::Create(browser_context());
+    auto web_contents = content::WebContentsTester::CreateTestWebContents(
+        browser_context(), site_instance);
+    const GURL kFailedUrl("https://example.com/error.html");
+    content::NavigationSimulator::NavigateAndFailFromBrowser(
+        web_contents.get(), kFailedUrl, net::ERR_FAILED);
+
+    auto* rfh = GetRenderFrameHost(web_contents.get());
+    ASSERT_TRUE(rfh->IsErrorDocument());
+
+    BrowserFrameContextData data(rfh);
+    EXPECT_TRUE(data.GetUrl().is_empty());
+  }
+
+  // Simulate an uncommitted cross-site navigation reaching `ReadyToCommit` to
+  // verify that `BrowserFrameContextData::GetUrl()` safely handles a frame in
+  // `content::RenderFrameHost::LifecycleState::kPendingCommit` without
+  // triggering assertion crashes.
+  {
+    auto site_instance = content::SiteInstance::Create(browser_context());
+    auto web_contents = content::WebContentsTester::CreateTestWebContents(
+        browser_context(), site_instance);
+    const GURL kInitialUrl("https://example.com/initial.html");
+    content::NavigationSimulator::NavigateAndCommitFromBrowser(
+        web_contents.get(), kInitialUrl);
+
+    const GURL kCrossSiteUrl("https://other-site.com/pending.html");
+    auto navigation = content::NavigationSimulator::CreateBrowserInitiated(
+        kCrossSiteUrl, web_contents.get());
+    navigation->ReadyToCommit();
+
+    content::RenderFrameHost* pending_rfh =
+        navigation->GetNavigationHandle()->GetRenderFrameHost();
+    ASSERT_TRUE(pending_rfh);
+    EXPECT_TRUE(pending_rfh->IsInLifecycleState(
+        content::RenderFrameHost::LifecycleState::kPendingCommit));
+
+    BrowserFrameContextData data(pending_rfh);
+    EXPECT_EQ(data.GetUrl(), GURL(url::kAboutBlankURL));
+  }
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/extension_util_unittest.cc b/extensions/browser/extension_util_unittest.cc
index c31bd0e6..5474520 100644
--- a/extensions/browser/extension_util_unittest.cc
+++ b/extensions/browser/extension_util_unittest.cc
@@ -8,14 +8,21 @@
 #include "base/path_service.h"
 #include "base/strings/strcat.h"
 #include "build/android_buildflags.h"
+#include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/site_instance.h"
+#include "content/public/browser/web_contents.h"
 #include "content/public/common/url_constants.h"
 #include "content/public/test/browser_task_environment.h"
+#include "content/public/test/navigation_simulator.h"
 #include "content/public/test/test_browser_context.h"
 #include "content/public/test/test_content_client_initializer.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/public/test/web_contents_tester.h"
+#include "extensions/browser/extensions_test.h"
 #include "extensions/common/extension_builder.h"
 #include "extensions/common/extension_paths.h"
 #include "extensions/common/extension_set.h"
+#include "net/base/net_errors.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "url/url_constants.h"
 
@@ -103,4 +110,98 @@
   EXPECT_EQ("", util::GetExtensionIdForSiteInstance(*https_site_instance));
 }
 
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns an empty
+// `GURL` when passed a null `content::RenderFrameHost*`.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_Null) {
+  content::RenderFrameHost* null_rfh = nullptr;
+  EXPECT_TRUE(util::GetURLForExtensionPermissionCheck(null_rfh).is_empty());
+}
+
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns an empty
+// `GURL` when passed an uncommitted initial frame.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_Uncommitted) {
+  std::unique_ptr<content::WebContents> web_contents =
+      content::WebContentsTester::CreateTestWebContents(browser_context(),
+                                                        /*instance=*/nullptr);
+  content::RenderFrameHost* main_rfh = web_contents->GetPrimaryMainFrame();
+  EXPECT_TRUE(util::GetURLForExtensionPermissionCheck(main_rfh).is_empty());
+}
+
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns the committed
+// `GURL` for a valid main frame navigation, and returns an empty `GURL` when
+// the main frame fails to navigate and commits an error document.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_MainFrame) {
+  // Create a test `content::WebContents`.
+  std::unique_ptr<content::WebContents> web_contents =
+      content::WebContentsTester::CreateTestWebContents(browser_context(),
+                                                        /*instance=*/nullptr);
+  content::RenderFrameHost* main_rfh = web_contents->GetPrimaryMainFrame();
+
+  // Simulate a successful navigation to a valid webpage in the main frame.
+  const GURL kValidUrl("https://example.com/page.html");
+  content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents.get(),
+                                                             kValidUrl);
+
+  // Verify that a committed valid navigation in the main frame returns the
+  // committed `GURL`.
+  EXPECT_EQ(kValidUrl, util::GetURLForExtensionPermissionCheck(main_rfh));
+
+  // Simulate a failed navigation in the main frame resulting in a top-level
+  // error document.
+  const GURL kFailedUrl("https://example.com/error.html");
+  content::RenderFrameHost* error_rfh =
+      content::NavigationSimulator::NavigateAndFailFromBrowser(
+          web_contents.get(), kFailedUrl, net::ERR_FAILED);
+  ASSERT_TRUE(error_rfh);
+
+  // Verify that a top-level error document returns an empty `GURL` instead of
+  // the failed destination target URL.
+  EXPECT_TRUE(util::GetURLForExtensionPermissionCheck(error_rfh).is_empty());
+}
+
+// Verifies that `util::GetURLForExtensionPermissionCheck` returns the committed
+// `GURL` for a valid child subframe, returns an empty `GURL` for an uncommitted
+// child subframe, and returns an empty `GURL` when the child subframe fails to
+// navigate and commits a subframe error document.
+TEST_F(ExtensionsTest, GetURLForExtensionPermissionCheck_Subframe) {
+  // Create a test `content::WebContents` with a committed main frame.
+  std::unique_ptr<content::WebContents> web_contents =
+      content::WebContentsTester::CreateTestWebContents(browser_context(),
+                                                        /*instance=*/nullptr);
+  const GURL kParentUrl("https://example.com/parent.html");
+  content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents.get(),
+                                                             kParentUrl);
+  content::RenderFrameHost* main_rfh = web_contents->GetPrimaryMainFrame();
+
+  // Create a child subframe inside the main frame.
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh)->AppendChild("child_frame");
+  ASSERT_TRUE(child_rfh);
+
+  // Verify that an uncommitted child subframe returns an empty `GURL`.
+  EXPECT_TRUE(util::GetURLForExtensionPermissionCheck(child_rfh).is_empty());
+
+  // Simulate a successful committed navigation in the child subframe.
+  const GURL kValidChildUrl("https://example.com/child.html");
+  content::NavigationSimulator::NavigateAndCommitFromDocument(kValidChildUrl,
+                                                              child_rfh);
+
+  // Verify that a committed valid navigation in the child subframe returns the
+  // child subframe's committed `GURL`.
+  EXPECT_EQ(kValidChildUrl, util::GetURLForExtensionPermissionCheck(child_rfh));
+
+  // Simulate a failed navigation in the child subframe resulting in a subframe
+  // error document.
+  const GURL kFailedChildUrl("https://example.com/child_error.html");
+  content::RenderFrameHost* error_child_rfh =
+      content::NavigationSimulator::NavigateAndFailFromDocument(
+          kFailedChildUrl, net::ERR_FAILED, child_rfh);
+  ASSERT_TRUE(error_child_rfh);
+
+  // Verify that a child subframe error document returns an empty `GURL` instead
+  // of the failed child navigation target URL.
+  EXPECT_TRUE(
+      util::GetURLForExtensionPermissionCheck(error_child_rfh).is_empty());
+}
+
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by [email protected]

Error-document renderer can impersonate an extension content script to externally_connectable targets

VULNERABILITY DETAILS

RequestContentScript::InstructRenderProcessToInject() grants browser-side content-script process identity before confirming that an extension script can run in the current document. It checks the failed navigation target through WebContents::GetLastCommittedURL(), but does not reject an error document.

A compromised renderer can send a registered declarativeContent selector from chrome-error://chromewebdata/. If the failed target is covered by extension A’s host permission, the browser records the error-document process as having run A’s content script:

if (!extension->permissions_data()->CanAccessPage(
        contents->GetLastCommittedURL(), tab_id, &error)) {
  return;
}
ScriptInjectionTracker::WillExecuteCode(...);  // identity granted here

No content script actually executes in the error document. The forged tracker state nevertheless passes ScriptInjectionTracker::DidProcessRunContentScriptFromExtension() when the same renderer opens an extension messaging channel as A. A target extension B whose externally_connectable.ids contains only A then receives runtime.onConnectExternal with sender.id == A.

Chromium’s compromised-renderer threat model explicitly requires the browser to prevent a renderer from spoofing MessageSender.id, and identifies ContentScriptTracker as the mechanism used to authenticate that identity: https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/compromised-renderers.md#messaging

This is an incomplete-fix variant of Chromium commit 2e4412b4632880203755104d93be611b12b3740a (issue 517153117). That fix added an IsErrorDocument() check to programmatic allFrames injection because the same tracker pollution bypassed process-isolation boundaries. The declarativeContent path was not covered.

SECURITY IMPACT

After compromising a web renderer, an attacker can impersonate an installed extension’s content-script principal to another extension. This bypasses the browser validation behind externally_connectable.ids; a target that uses the source extension ID to authorize messages can disclose data or perform actions for the wrong principal.

The PoC uses two local extensions and makes the target display the accepted sender identity in a visible extension page. It does not claim that extension code executes in the error document, ordinary web-content reachability without a renderer compromise, or a complete sandbox escape.

The protected action is the browser accepting and delivering a content-script channel whose source ID is restricted by externally_connectable.ids. Any extension data or action placed behind that accepted channel would inherit the wrong browser-authenticated principal; the report does not depend on a target extension voluntarily trusting an unverified web-page field.

VERSION

  • Reproduced product build: Chromium 152.0.7923.0, local main/Dev-equivalent build, normal sandbox
  • Reproduced source commit: b9d7246399df323f397e1a136b93ef75f4268cb0
  • Operating System: macOS 26.5.2, arm64
  • Current Stable 151 branch-head 7922 checked (2026-07-17): d8c45d3713d6b4907ce9cde2c8fc4407f3cd88e3
  • Latest fetched main checked (2026-07-17): 792a0ccd2b48c4fcaac7aabde2eaf890e50e52d6

The vulnerable content_action.cc and PoC renderer target have identical Git blobs in the reproduced build, current Stable 151 branch, and latest main. Main contains a later messaging change, but the kContentScript validation still relies only on DidProcessRunContentScriptFromExtension(process, extension_id). The minimized no-log poc.patch passes git apply --cached --check against the reproduced checkout.

REPRODUCTION CASE

Attachments: poc.patch and poc.py.

poc.patch changes only renderer code and models a compromised renderer. It uses the error document’s real LocalFrameHost; no browser authorization, extension service, or test code is modified.

MojoJS is insufficient because extensions::mojom::LocalFrameHost is an associated interface bound internally to the current RenderFrame; it is not exposed through the page’s BrowserInterfaceBroker. The PoC must also send the messages over the real error document’s frame route. The patch is process- and URL-guarded and contains no privileged-process changes or success logging. It is the dynamically validated renderer patch with only diagnostic LOG statements removed; the two LocalFrameHost calls, ordering, endpoint IDs, and message-port plumbing are unchanged.

  1. Generate the two extensions and start the local launcher:

    python3 poc.py 8844 --output /tmp/ext0231
    
  2. Apply the renderer patch and build Chrome:

    git apply poc.patch
    autoninja -C out/Release chrome
    
  3. Start the product with normal sandboxing and a fresh profile:

    out/Release/Chromium.app/Contents/MacOS/Chromium \
      --user-data-dir=/tmp/ext0231-profile \
      --disable-extensions-except=/tmp/ext0231/source_extension,/tmp/ext0231/target_extension \
      --load-extension=/tmp/ext0231/source_extension,/tmp/ext0231/target_extension
    
  4. The source extension registers the declarativeContent rule and opens the launcher. The launcher navigates to an unused local port, producing a real chrome-error://chromewebdata/ document. Do not run a server on port 8766.

  5. The target extension opens a visible result page containing:

    RESULT=EXTENSION_IDENTITY_BYPASS
    senderId=okcefaocmdlaoolknaodpdhdbbmalmob
    senderUrl=chrome-error://chromewebdata/
    senderOrigin=null
    channelName=ext0231-error-document
    
  6. Reverse the patch, rebuild, and repeat with a fresh profile. The same error document appears, but the target result page is not opened. The underlying product A/B observed tracker/channel/listener counts of 0/0/0 for clean renderer and 1/1/1 for the renderer-only simulation.

ROOT CAUSE

The browser treats host permission for the failed target URL as proof that a content script can execute in the current RFH. It then writes process-scoped identity before checking the live LocalFrame, and messaging later consumes that historical process identity without binding it to the current document.

SUGGESTED FIX

Reject IsErrorDocument() before WillExecuteCode() in RequestContentScript::InstructRenderProcessToInject(), and move tracker mutation after all injection preconditions. Messaging should additionally bind a content-script endpoint to the current document rather than only historical process state.

CREDIT INFORMATION

Reporter credit: 章鱼哥@aipyaipy.com

View on issue tracker