Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Extensions
DescriptionInsufficient validation of untrusted input in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker516813317
Fix commit71365f8920c4 (chromium/src) +143/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TestMessagePort
chrome/browser/extensions/extension_security_exploit_browsertest.cc
modified

Files Changed

  • chrome/browser/extensions/extension_security_exploit_browsertest.cc
  • extensions/browser/api/messaging/message_service.cc
From 71365f8920c4b1af93544ed85929e3fecf058c44 Mon Sep 17 00:00:00 2001
From: Justin Lulejian <[email protected]>
Date: Thu, 25 Jun 2026 17:52:45 -0700
Subject: [PATCH] [Extensions] Block externally_connectable messaging from error pages.

Previously, a page committing as an error document could still establish
connections to externally_connectable extensions. This was because
MessageService::OpenChannelToExtension validated the connection request
against the frame's GetLastCommittedURL() but did not check if the
document was actually an error page (IsErrorDocument()). Since the last
committed URL of an error page reflects the failed navigation target,
this made it seems like a renderer hosting the error page was actually
hosting the target URL (from the perspective of messaging).

After this change, connection requests from frames that commit as error
documents are rejected.

This is accomplished by adding a check for !IsErrorDocument() when
validating externally_connectable matches in
MessageService::OpenChannelToExtension.

TAG=agy
CONV=e9ac3cb4-8403-4361-9db3-3170b15055e0

Fixed: 516813317
Change-Id: I1fad94b12ffd37d5fb0ff49271869b35966b5f8d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7994200
Auto-Submit: Justin Lulejian <[email protected]>
Reviewed-by: Andrea Orru <[email protected]>
Commit-Queue: Justin Lulejian <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1652841}
---

diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 5e85311..ee7528c5 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -48,6 +48,7 @@
 #include "extensions/common/extension_features.h"
 #include "extensions/common/mojom/event_router.mojom.h"
 #include "extensions/common/mojom/frame.mojom-test-utils.h"
+#include "extensions/common/mojom/message_port.mojom.h"
 #include "extensions/common/mojom/service_worker_host.mojom-test-utils.h"
 #include "extensions/test/extension_test_message_listener.h"
 #include "extensions/test/test_extension_dir.h"
@@ -81,6 +82,36 @@
   return response;
 }
 
+// A mock `mojom::MessagePort` implementation that allows tests to intercept
+// communication from the browser-side `MessageService` (e.g., to verify
+// connection success/failure and capture disconnect errors).
+class TestMessagePort : public mojom::MessagePort {
+ public:
+  explicit TestMessagePort(base::RepeatingClosure quit_closure)
+      : quit_closure_(std::move(quit_closure)) {}
+
+  bool got_message() const { return got_message_; }
+
+  const std::optional<std::string>& disconnect_error() const {
+    return disconnect_error_;
+  }
+
+ private:
+  void DispatchDisconnect(const std::string& error) override {
+    disconnect_error_ = error;
+    quit_closure_.Run();
+  }
+
+  void DeliverMessage(Message message) override {
+    got_message_ = true;
+    quit_closure_.Run();
+  }
+
+  bool got_message_ = false;
+  std::optional<std::string> disconnect_error_;
+  base::RepeatingClosure quit_closure_;
+};
+
 }  // namespace
 
 // ExtensionFrameHostInterceptor is a helper for:
@@ -1896,4 +1927,109 @@
   EXPECT_EQ(api_error, "Cannot call extension APIs from error pages.");
 }
 
+// Verifies that a renderer cannot send messages to an `externally_connectable`
+// extension by using a subframe error page.
+// The test navigates to a page with CSP that blocks a subframe, causing it to
+// commit as an error document. It then simulates a renderer attempting to
+// open a channel to the extension on behalf of the subframe.
+// We expect the connection to fail because the subframe is an error document.
+// Regression test for crbug.com/516813317.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromErrorPageSubframe_ExternallyConnectable) {
+  // Install an extension connectable only from `chromewebstore.google.com`.
+  TestExtensionDir extension_dir;
+  extension_dir.WriteManifest(R"({
+    "name": "Connectable Extension",
+    "version": "1.0",
+    "manifest_version": 3,
+    "externally_connectable": {
+      "matches": ["https://chromewebstore.google.com/*"]
+    },
+    "background": {"service_worker": "background.js"}
+  })");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+    chrome.runtime.onConnectExternal.addListener((port) => {
+      chrome.test.sendMessage("connected");
+      port.postMessage("hello");
+    });
+  )");
+  const Extension* extension = LoadExtension(extension_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  ExtensionTestMessageListener connect_listener("connected");
+
+  // Navigate to a.com with a CSP blocking a subframe loading
+  // https://chromewebstore.google.com/.
+  const GURL victim_url("https://chromewebstore.google.com/");
+  GURL test_page_url =
+      embedded_test_server()->GetURL("a.com", "/csp_block.html");
+
+  auto* web_contents = GetActiveWebContents();
+  // NavigateToURL() only waits for the main frame navigation to commit. We
+  // must use `TestNavigationObserver` to wait for the subframe navigation to
+  // finish committing as an error document.
+  content::TestNavigationObserver navigation_observer(web_contents);
+  std::ignore = NavigateToURL(web_contents, test_page_url);
+  {
+    SCOPED_TRACE("Waiting for CSP block navigation to complete");
+    navigation_observer.Wait();
+  }
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  content::RenderFrameHost* subframe =
+      content::ChildFrameAt(main_frame, /*index=*/0);
+  ASSERT_TRUE(subframe);
+
+  // Verify the subframe commits as an error document in the parent's process
+  // with `GetLastCommittedURL()` returning
+  // "https://chromewebstore.google.com/".
+  EXPECT_TRUE(subframe->IsErrorDocument());
+  EXPECT_EQ(subframe->GetLastCommittedURL(), victim_url);
+  EXPECT_EQ(main_frame->GetProcess(), subframe->GetProcess());
+
+  // Simulate the renderer opening a channel to the extension on behalf of the
+  // subframe using `MessageService::OpenChannelToExtension`.
+  content::RenderProcessHost* process = subframe->GetProcess();
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(subframe->GetRoutingID()));
+
+  // Set up the message channel endpoints.
+  PortId source_port_id(base::UnguessableToken::Create(), /*port_number=*/0,
+                        /*is_opener=*/true, mojom::SerializationFormat::kJson);
+
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForWebPage();
+  info.source_url = test_page_url;
+
+  // Bind the test-side port receiver to capture the connection response.
+  base::RunLoop run_loop;
+  TestMessagePort test_port(run_loop.QuitClosure());
+  mojo::AssociatedReceiver<mojom::MessagePort> port_receiver(&test_port);
+
+  // Create endpoints. Use `BindNewEndpointAndPassDedicatedRemote()` to avoid
+  // Mojo association crashes when simulating the IPC directly.
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port_remote =
+      port_receiver.BindNewEndpointAndPassDedicatedRemote();
+
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host_receiver;
+  auto port_host_remote = port_host_receiver.InitWithNewEndpointAndPassRemote();
+
+  // Simulate the renderer calling OpenChannelToExtension directly.
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source, source_port_id, info, mojom::ChannelType::kConnect,
+      "test_channel", std::move(port_remote), std::move(port_host_receiver));
+
+  // We expect the connection to fail since error documents shouldn't be able
+  // to send messages.
+  {
+    SCOPED_TRACE("Waiting for port disconnection");
+    run_loop.Run();
+  }
+  EXPECT_FALSE(connect_listener.was_satisfied());
+  EXPECT_FALSE(test_port.got_message());
+  EXPECT_EQ("Could not establish connection. Receiving end does not exist.",
+            test_port.disconnect_error());
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/api/messaging/message_service.cc b/extensions/browser/api/messaging/message_service.cc
index 2a4b0942..8be9b3fa 100644
--- a/extensions/browser/api/messaging/message_service.cc
+++ b/extensions/browser/api/messaging/message_service.cc
@@ -542,9 +542,13 @@
         DCHECK_EQ(MessagingEndpoint::Relationship::kExternalWebPage,
                   relationship);
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 5e85311..ee7528c5 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -48,6 +48,7 @@
 #include "extensions/common/extension_features.h"
 #include "extensions/common/mojom/event_router.mojom.h"
 #include "extensions/common/mojom/frame.mojom-test-utils.h"
+#include "extensions/common/mojom/message_port.mojom.h"
 #include "extensions/common/mojom/service_worker_host.mojom-test-utils.h"
 #include "extensions/test/extension_test_message_listener.h"
 #include "extensions/test/test_extension_dir.h"
@@ -81,6 +82,36 @@
   return response;
 }
 
+// A mock `mojom::MessagePort` implementation that allows tests to intercept
+// communication from the browser-side `MessageService` (e.g., to verify
+// connection success/failure and capture disconnect errors).
+class TestMessagePort : public mojom::MessagePort {
+ public:
+  explicit TestMessagePort(base::RepeatingClosure quit_closure)
+      : quit_closure_(std::move(quit_closure)) {}
+
+  bool got_message() const { return got_message_; }
+
+  const std::optional<std::string>& disconnect_error() const {
+    return disconnect_error_;
+  }
+
+ private:
+  void DispatchDisconnect(const std::string& error) override {
+    disconnect_error_ = error;
+    quit_closure_.Run();
+  }
+
+  void DeliverMessage(Message message) override {
+    got_message_ = true;
+    quit_closure_.Run();
+  }
+
+  bool got_message_ = false;
+  std::optional<std::string> disconnect_error_;
+  base::RepeatingClosure quit_closure_;
+};
+
 }  // namespace
 
 // ExtensionFrameHostInterceptor is a helper for:
@@ -1896,4 +1927,109 @@
   EXPECT_EQ(api_error, "Cannot call extension APIs from error pages.");
 }
 
+// Verifies that a renderer cannot send messages to an `externally_connectable`
+// extension by using a subframe error page.
+// The test navigates to a page with CSP that blocks a subframe, causing it to
+// commit as an error document. It then simulates a renderer attempting to
+// open a channel to the extension on behalf of the subframe.
+// We expect the connection to fail because the subframe is an error document.
+// Regression test for crbug.com/516813317.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromErrorPageSubframe_ExternallyConnectable) {
+  // Install an extension connectable only from `chromewebstore.google.com`.
+  TestExtensionDir extension_dir;
+  extension_dir.WriteManifest(R"({
+    "name": "Connectable Extension",
+    "version": "1.0",
+    "manifest_version": 3,
+    "externally_connectable": {
+      "matches": ["https://chromewebstore.google.com/*"]
+    },
+    "background": {"service_worker": "background.js"}
+  })");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+    chrome.runtime.onConnectExternal.addListener((port) => {
+      chrome.test.sendMessage("connected");
+      port.postMessage("hello");
+    });
+  )");
+  const Extension* extension = LoadExtension(extension_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  ExtensionTestMessageListener connect_listener("connected");
+
+  // Navigate to a.com with a CSP blocking a subframe loading
+  // https://chromewebstore.google.com/.
+  const GURL victim_url("https://chromewebstore.google.com/");
+  GURL test_page_url =
+      embedded_test_server()->GetURL("a.com", "/csp_block.html");
+
+  auto* web_contents = GetActiveWebContents();
+  // NavigateToURL() only waits for the main frame navigation to commit. We
+  // must use `TestNavigationObserver` to wait for the subframe navigation to
+  // finish committing as an error document.
+  content::TestNavigationObserver navigation_observer(web_contents);
+  std::ignore = NavigateToURL(web_contents, test_page_url);
+  {
+    SCOPED_TRACE("Waiting for CSP block navigation to complete");
+    navigation_observer.Wait();
+  }
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  content::RenderFrameHost* subframe =
+      content::ChildFrameAt(main_frame, /*index=*/0);
+  ASSERT_TRUE(subframe);
+
+  // Verify the subframe commits as an error document in the parent's process
+  // with `GetLastCommittedURL()` returning
+  // "https://chromewebstore.google.com/".
+  EXPECT_TRUE(subframe->IsErrorDocument());
+  EXPECT_EQ(subframe->GetLastCommittedURL(), victim_url);
+  EXPECT_EQ(main_frame->GetProcess(), subframe->GetProcess());
+
+  // Simulate the renderer opening a channel to the extension on behalf of the
+  // subframe using `MessageService::OpenChannelToExtension`.
+  content::RenderProcessHost* process = subframe->GetProcess();
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(subframe->GetRoutingID()));
+
+  // Set up the message channel endpoints.
+  PortId source_port_id(base::UnguessableToken::Create(), /*port_number=*/0,
+                        /*is_opener=*/true, mojom::SerializationFormat::kJson);
+
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForWebPage();
+  info.source_url = test_page_url;
+
+  // Bind the test-side port receiver to capture the connection response.
+  base::RunLoop run_loop;
+  TestMessagePort test_port(run_loop.QuitClosure());
+  mojo::AssociatedReceiver<mojom::MessagePort> port_receiver(&test_port);
+
+  // Create endpoints. Use `BindNewEndpointAndPassDedicatedRemote()` to avoid
+  // Mojo association crashes when simulating the IPC directly.
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port_remote =
+      port_receiver.BindNewEndpointAndPassDedicatedRemote();
+
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host_receiver;
+  auto port_host_remote = port_host_receiver.InitWithNewEndpointAndPassRemote();
+
+  // Simulate the renderer calling OpenChannelToExtension directly.
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source, source_port_id, info, mojom::ChannelType::kConnect,
+      "test_channel", std::move(port_remote), std::move(port_host_receiver));
+
+  // We expect the connection to fail since error documents shouldn't be able
+  // to send messages.
+  {
+    SCOPED_TRACE("Waiting for port disconnection");
+    run_loop.Run();
+  }
+  EXPECT_FALSE(connect_listener.was_satisfied());
+  EXPECT_FALSE(test_port.got_message());
+  EXPECT_EQ("Could not establish connection. Receiving end does not exist.",
+            test_port.disconnect_error());
+}
+
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of externally_connectable matching via subframe error page in compromised renderer

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 process can potentially bypass the browser-enforced ’externally_connectable.matches’ validation to connect to privileged extensions. By triggering a blocked subframe navigation (e.g. via CSP) that commits an error document in the current process, the attacker’s frame gets associated with the blocked cross-origin URL. Since browser-side messaging validation lacks an error document check, the compromised renderer can establish an unauthorized messaging port.

Affected files:

  • extensions/browser/api/messaging/message_service.cc
  • extensions/browser/api/messaging/message_service_bindings.cc

Estimated timestamp from git blame: 2019-12-17

Potential Security Boundary Bypass via Subframe Error Documents

There is a potential vulnerability where a compromised renderer process can bypass the browser-enforced externally_connectable match restrictions to communicate directly with extensions. This could allow an attacker-controlled renderer to impersonate a trusted domain (such as https://victim.com/) to establish messaging channels with extensions that trust that domain.

Root Cause Analysis

When a page establishes a connection to an extension, the browser-side MessageService::OpenChannelToExtension validates that the source page matches the extension’s externally_connectable.matches patterns. It performs this validation by checking the source frame’s last committed URL:

is_externally_connectable = externally_connectable->matches.MatchesURL(
    source_render_frame_host->GetLastCommittedURL());

However, this validation does not verify if the source frame is currently displaying an error page (e.g. by checking source_render_frame_host->IsErrorDocument()). This can potentially be exploited by a compromised renderer process in the following manner:

  1. Subframe Error Pages are Not Isolated: Subframe error-page process isolation is disabled in Chrome (to minimize process overhead). FrameTreeNode::IsErrorPageIsolationEnabled() returns false for non-main frames.
  2. Commit in Current Process on Blocked Navigation: If a renderer-initiated subframe navigation is blocked (e.g., by CSP ERR_BLOCKED_BY_CSP), ComputeErrorPageProcess() determines that it should commit inside the initiator’s process (ErrorPageProcess::kCurrentProcess), which is the attacker’s renderer process.
  3. Last Committed URL Set to Unreachable Destination: During the commit of the error page, the renderer overrides the loading URL so that GetLastCommittedURL() on the browser-side RenderFrameHost returns the failed destination URL (e.g., https://victim.com/) rather than the internal chrome-error:// URL. Although the document’s origin is sanitized to an opaque origin, the URL remains https://victim.com/.
  4. IPC Security Check Bypass: When a compromised renderer requests extensions.mojom.LocalFrameHost::OpenChannelToExtension, passing an empty source_url will bypass browser-side child process security checks because IsValidSourceUrl() immediately returns true for empty URLs.
  5. Validation Failure: When MessageService processes the request, the relationship is classified as kExternalWebPage. Because GetLastCommittedURL() on the subframe’s RenderFrameHost returns the failed destination URL (https://victim.com/), the MatchesURL check evaluates to true, successfully opening the messaging port.

Note: These steps are based on static analysis of the codebase, as our tooling currently does not have the ability to run code and verify this via an active proof of concept.

Potential Attack Steps

  1. An extension with ID X is installed, containing the following manifest entry:
    "externally_connectable": {
      "matches": ["https://victim.com/*"]
    }
    
  2. The user navigates to an attacker-controlled site https://attacker.com/p.html that serves a restrictive CSP (Content-Security-Policy: frame-src 'none') and embeds <iframe src="https://victim.com/"></iframe>.
  3. The browser blocks the subframe navigation with ERR_BLOCKED_BY_CSP. The error page commits within the attacker’s renderer process. The subframe’s RenderFrameHost ends up with GetLastCommittedURL() == "https://victim.com/" and IsErrorDocument() == true.
  4. Using a compromised renderer primitive, the attacker interacts with the frame’s associated extensions.mojom.LocalFrameHost interface and invokes OpenChannelToExtension with source_endpoint.type = kWebPage and an empty source_url.
  5. The browser-side validation evaluates the rule against the subframe’s committed URL https://victim.com/, passes the MatchesURL check, and establishes a messaging channel to the target extension X.

Suggested Fix

To remediate this issue, MessageService::OpenChannelToExtension should check whether the source RenderFrameHost is an error document, and reject any messaging connection attempts originating from one:

if (source_render_frame_host->IsErrorDocument()) {
  opener_port->DispatchOnDisconnect(kReceivingEndDoesntExistError);
  return;
}

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