Overview

High
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
Tracker511765713
Fix commit885bc0953c95 (chromium/src) +309/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • chrome/browser/extensions/extension_security_exploit_browsertest.cc
From 885bc0953c95342e3e40be8ca2a87aa4d94e1923 Mon Sep 17 00:00:00 2001
From: Devlin Cronin <[email protected]>
Date: Fri, 15 May 2026 16:34:49 -0700
Subject: [PATCH] [Extensions] Don't allow message connections from sandboxed pages

Extensions can sandbox their pages via the manifest key "sandbox";
pages specified in this will be hosted in a separate process and
will not have any extension API access (including access to messaging).
Validate that attempts to open extension message channels from these
processes are disallowed.

Add a number of related browser tests for both "happy" and "sad" flows:
* OpenChannelToExtensionExploitTest.FromSandboxedPage_SpoofedSourceUrl:
  The flow described above. A sandboxed extension page tries to open a
  message channel. The process should be terminated for a bad message.
* OpenChannelToExtensionExploitTest.
      FromSandboxedPage_AboutBlankChild_SpoofedSourceUrl: Same as above,
  but with an about:blank child frame initiating the message.
* SandboxedPagesTest.ManifestV3MessagingBindingsWithheld: Verifies that
  extension messaging APIs are withheld in sandboxed pages and these
  pages are hosted in separate processes.
* SandboxedPagesTest.Html5SandboxedIframeMessagingBindingsExposed:
  Verifies messaging bindings are *not* withheld in frames sandboxed via
  the `sandbox` html frame attribute. These should still be able to send
  messages.

Bug: 511765713
Change-Id: I0118926a451a194baab5c075565b127023cad508
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7851421
Reviewed-by: Nasko Oskov <[email protected]>
Commit-Queue: Devlin Cronin <[email protected]>
Reviewed-by: Luc Nguyen <[email protected]>
Reviewed-by: Łukasz Anforowicz <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1631630}
---

diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index d4d9d183..1a3e243 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -27,6 +27,8 @@
 #include "content/public/common/content_client.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/api/messaging/channel_endpoint.h"
+#include "extensions/browser/api/messaging/message_service.h"
 #include "extensions/browser/api/storage/storage_api.h"
 #include "extensions/browser/background_script_executor.h"
 #include "extensions/browser/bad_message.h"
@@ -38,6 +40,8 @@
 #include "extensions/browser/renderer_startup_helper.h"
 #include "extensions/browser/script_injection_tracker.h"
 #include "extensions/browser/service_worker/service_worker_host.h"
+#include "extensions/common/api/messaging/messaging_endpoint.h"
+#include "extensions/common/api/messaging/port_id.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension_features.h"
 #include "extensions/common/mojom/event_router.mojom.h"
@@ -582,6 +586,176 @@
   EXPECT_EQ(bad_message::EMF_INVALID_SOURCE_URL, kill_waiter.Wait());
 }
 
+// Tests a sandboxed page sending a spoofed source URL.
+// Regression test for https://crbug.com/511765713.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromSandboxedPage_SpoofedSourceUrl) {
+  TestExtensionDir dir;
+  dir.WriteManifest(R"({
+    "name": "Sandboxed Spoofing Test",
+    "version": "1.0",
+    "manifest_version": 3,
+    "sandbox": {
+      "pages": ["sandbox.html"]
+    }
+  })");
+  dir.WriteFile(FILE_PATH_LITERAL("sandbox.html"), "<p>sandbox</p>");
+  dir.WriteFile(FILE_PATH_LITERAL("main.html"), "<p>main</p>");
+  const Extension* extension = LoadExtension(dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL sandbox_url = extension->GetResourceURL("sandbox.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, sandbox_url));
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  content::RenderProcessHost* process = main_frame->GetProcess();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(process);
+
+  // Construct ChannelEndpoint for the sandboxed frame.
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(main_frame->GetRoutingID()));
+
+  // Construct spoofed ExternalConnectionInfo.
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForExtension(extension->id());
+  // Spoof source_url as main.html.
+  info.source_url = extension->GetResourceURL("main.html");
+
+  // We can't easily intercept and manipulate an IPC here because sandboxed
+  // pages don't have access to extension APIs. Instead, we create fake port
+  // endpoints and directly call the IPC message method. This wouldn't succeed
+  // in connecting, but they would fail for different reasons if validation
+  // didn't fail.
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port;
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host;
+  std::ignore = port.InitWithNewEndpointAndPassReceiver();
+  std::ignore = port_host.InitWithNewEndpointAndPassRemote();
+
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source,
+      PortId(base::UnguessableToken::Create(), 0, true,
+             mojom::SerializationFormat::kJson),
+      info, mojom::ChannelType::kSendMessage, "test_channel", std::move(port),
+      std::move(port_host));
+
+  EXPECT_EQ(bad_message::EMF_INVALID_MESSAGE_FROM_SANDBOXED_PROCESS,
+            kill_waiter.Wait());
+}
+
+// Same as `FromSandboxedPage_SpoofedSourceUrl` above, but exercises the flow
+// from an about:blank child.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromSandboxedPage_AboutBlankChild_SpoofedSourceUrl) {
+  TestExtensionDir dir;
+  dir.WriteManifest(R"({
+    "name": "Sandboxed Spoofing Test - AboutBlank",
+    "version": "1.0",
+    "manifest_version": 3,
+    "sandbox": {
+      "pages": ["sandbox.html"]
+    }
+  })");
+  dir.WriteFile(FILE_PATH_LITERAL("sandbox.html"),
+                "<html><body><iframe src=\"about:blank\"></body></html>");
+  dir.WriteFile(FILE_PATH_LITERAL("main.html"), "<p>main</p>");
+  const Extension* extension = LoadExtension(dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL sandbox_url = extension->GetResourceURL("sandbox.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, sandbox_url));
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  // Get the about:blank child frame.
+  content::RenderFrameHost* child_frame = content::ChildFrameAt(main_frame, 0);
+  ASSERT_TRUE(child_frame);
+  EXPECT_EQ(GURL(url::kAboutBlankURL), child_frame->GetLastCommittedURL());
+
+  content::RenderProcessHost* process = child_frame->GetProcess();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(process);
+
+  // Construct ChannelEndpoint for the sandboxed about:blank child frame.
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(child_frame->GetRoutingID()));
+
+  // Construct spoofed ExternalConnectionInfo.
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForExtension(extension->id());
+  // Spoof source_url as main.html.
+  info.source_url = extension->GetResourceURL("main.html");
+
+  // See test above for the explanation of why we do it this way.
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port;
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host;
+  std::ignore = port.InitWithNewEndpointAndPassReceiver();
+  std::ignore = port_host.InitWithNewEndpointAndPassRemote();
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source,
+      PortId(base::UnguessableToken::Create(), 0, true,
+             mojom::SerializationFormat::kJson),
+      info, mojom::ChannelType::kSendMessage, "test_channel", std::move(port),
+      std::move(port_host));
+
+  EXPECT_EQ(bad_message::EMF_INVALID_MESSAGE_FROM_SANDBOXED_PROCESS,
+            kill_waiter.Wait());
+}
+
+// Same as above, but without a spoofed URL. This should *still* be disallowed
+// because we don't allow connections from sandboxed pages.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromSandboxedPage_NoSpoofedSourceUrl) {
+  TestExtensionDir dir;
+  dir.WriteManifest(R"({
+    "name": "Sandboxed Spoofing Test",
+    "version": "1.0",
+    "manifest_version": 3,
+    "sandbox": {
+      "pages": ["sandbox.html"]
+    }
+  })");
+  dir.WriteFile(FILE_PATH_LITERAL("sandbox.html"), "<p>sandbox</p>");
+  dir.WriteFile(FILE_PATH_LITERAL("main.html"), "<p>main</p>");
+  const Extension* extension = LoadExtension(dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL sandbox_url = extension->GetResourceURL("sandbox.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, sandbox_url));
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 d4d9d183..1a3e243 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -27,6 +27,8 @@
 #include "content/public/common/content_client.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/api/messaging/channel_endpoint.h"
+#include "extensions/browser/api/messaging/message_service.h"
 #include "extensions/browser/api/storage/storage_api.h"
 #include "extensions/browser/background_script_executor.h"
 #include "extensions/browser/bad_message.h"
@@ -38,6 +40,8 @@
 #include "extensions/browser/renderer_startup_helper.h"
 #include "extensions/browser/script_injection_tracker.h"
 #include "extensions/browser/service_worker/service_worker_host.h"
+#include "extensions/common/api/messaging/messaging_endpoint.h"
+#include "extensions/common/api/messaging/port_id.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension_features.h"
 #include "extensions/common/mojom/event_router.mojom.h"
@@ -582,6 +586,176 @@
   EXPECT_EQ(bad_message::EMF_INVALID_SOURCE_URL, kill_waiter.Wait());
 }
 
+// Tests a sandboxed page sending a spoofed source URL.
+// Regression test for https://crbug.com/511765713.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromSandboxedPage_SpoofedSourceUrl) {
+  TestExtensionDir dir;
+  dir.WriteManifest(R"({
+    "name": "Sandboxed Spoofing Test",
+    "version": "1.0",
+    "manifest_version": 3,
+    "sandbox": {
+      "pages": ["sandbox.html"]
+    }
+  })");
+  dir.WriteFile(FILE_PATH_LITERAL("sandbox.html"), "<p>sandbox</p>");
+  dir.WriteFile(FILE_PATH_LITERAL("main.html"), "<p>main</p>");
+  const Extension* extension = LoadExtension(dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL sandbox_url = extension->GetResourceURL("sandbox.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, sandbox_url));
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  content::RenderProcessHost* process = main_frame->GetProcess();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(process);
+
+  // Construct ChannelEndpoint for the sandboxed frame.
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(main_frame->GetRoutingID()));
+
+  // Construct spoofed ExternalConnectionInfo.
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForExtension(extension->id());
+  // Spoof source_url as main.html.
+  info.source_url = extension->GetResourceURL("main.html");
+
+  // We can't easily intercept and manipulate an IPC here because sandboxed
+  // pages don't have access to extension APIs. Instead, we create fake port
+  // endpoints and directly call the IPC message method. This wouldn't succeed
+  // in connecting, but they would fail for different reasons if validation
+  // didn't fail.
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port;
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host;
+  std::ignore = port.InitWithNewEndpointAndPassReceiver();
+  std::ignore = port_host.InitWithNewEndpointAndPassRemote();
+
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source,
+      PortId(base::UnguessableToken::Create(), 0, true,
+             mojom::SerializationFormat::kJson),
+      info, mojom::ChannelType::kSendMessage, "test_channel", std::move(port),
+      std::move(port_host));
+
+  EXPECT_EQ(bad_message::EMF_INVALID_MESSAGE_FROM_SANDBOXED_PROCESS,
+            kill_waiter.Wait());
+}
+
+// Same as `FromSandboxedPage_SpoofedSourceUrl` above, but exercises the flow
+// from an about:blank child.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromSandboxedPage_AboutBlankChild_SpoofedSourceUrl) {
+  TestExtensionDir dir;
+  dir.WriteManifest(R"({
+    "name": "Sandboxed Spoofing Test - AboutBlank",
+    "version": "1.0",
+    "manifest_version": 3,
+    "sandbox": {
+      "pages": ["sandbox.html"]
+    }
+  })");
+  dir.WriteFile(FILE_PATH_LITERAL("sandbox.html"),
+                "<html><body><iframe src=\"about:blank\"></body></html>");
+  dir.WriteFile(FILE_PATH_LITERAL("main.html"), "<p>main</p>");
+  const Extension* extension = LoadExtension(dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL sandbox_url = extension->GetResourceURL("sandbox.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, sandbox_url));
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  // Get the about:blank child frame.
+  content::RenderFrameHost* child_frame = content::ChildFrameAt(main_frame, 0);
+  ASSERT_TRUE(child_frame);
+  EXPECT_EQ(GURL(url::kAboutBlankURL), child_frame->GetLastCommittedURL());
+
+  content::RenderProcessHost* process = child_frame->GetProcess();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(process);
+
+  // Construct ChannelEndpoint for the sandboxed about:blank child frame.
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(child_frame->GetRoutingID()));
+
+  // Construct spoofed ExternalConnectionInfo.
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForExtension(extension->id());
+  // Spoof source_url as main.html.
+  info.source_url = extension->GetResourceURL("main.html");
+
+  // See test above for the explanation of why we do it this way.
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port;
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host;
+  std::ignore = port.InitWithNewEndpointAndPassReceiver();
+  std::ignore = port_host.InitWithNewEndpointAndPassRemote();
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source,
+      PortId(base::UnguessableToken::Create(), 0, true,
+             mojom::SerializationFormat::kJson),
+      info, mojom::ChannelType::kSendMessage, "test_channel", std::move(port),
+      std::move(port_host));
+
+  EXPECT_EQ(bad_message::EMF_INVALID_MESSAGE_FROM_SANDBOXED_PROCESS,
+            kill_waiter.Wait());
+}
+
+// Same as above, but without a spoofed URL. This should *still* be disallowed
+// because we don't allow connections from sandboxed pages.
+IN_PROC_BROWSER_TEST_F(OpenChannelToExtensionExploitTest,
+                       FromSandboxedPage_NoSpoofedSourceUrl) {
+  TestExtensionDir dir;
+  dir.WriteManifest(R"({
+    "name": "Sandboxed Spoofing Test",
+    "version": "1.0",
+    "manifest_version": 3,
+    "sandbox": {
+      "pages": ["sandbox.html"]
+    }
+  })");
+  dir.WriteFile(FILE_PATH_LITERAL("sandbox.html"), "<p>sandbox</p>");
+  dir.WriteFile(FILE_PATH_LITERAL("main.html"), "<p>main</p>");
+  const Extension* extension = LoadExtension(dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL sandbox_url = extension->GetResourceURL("sandbox.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, sandbox_url));
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  content::RenderProcessHost* process = main_frame->GetProcess();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(process);
+
+  // Construct ChannelEndpoint for the sandboxed frame.
+  ChannelEndpoint source(profile(), process->GetDeprecatedID(),
+                         PortContext::ForFrame(main_frame->GetRoutingID()));
+
+  // Construct spoofed ExternalConnectionInfo.
+  mojom::ExternalConnectionInfo info;
+  info.target_id = extension->id();
+  info.source_endpoint = MessagingEndpoint::ForExtension(extension->id());
+  // Use the "real" source_url.
+  info.source_url = extension->GetResourceURL("sandbox.html");
+
+  mojo::PendingAssociatedRemote<mojom::MessagePort> port;
+  mojo::PendingAssociatedReceiver<mojom::MessagePortHost> port_host;
+  std::ignore = port.InitWithNewEndpointAndPassReceiver();
+  std::ignore = port_host.InitWithNewEndpointAndPassRemote();
+
+  MessageService::Get(profile())->OpenChannelToExtension(
+      source,
+      PortId(base::UnguessableToken::Create(), 0, true,
+             mojom::SerializationFormat::kJson),
+      info, mojom::ChannelType::kSendMessage, "test_channel", std::move(port),
+      std::move(port_host));
+
+  EXPECT_EQ(bad_message::EMF_INVALID_MESSAGE_FROM_SANDBOXED_PROCESS,
+            kill_waiter.Wait());
+}
+
 // This is a regression test for https://crbug.com/40875650.
 IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
                        SendMessageFromContentScriptInDataUrlFrame) {
diff --git a/chrome/browser/extensions/sandboxed_pages_apitest.cc b/chrome/browser/extensions/sandboxed_pages_apitest.cc
index c620bcd..efdddfc 100644
--- a/chrome/browser/extensions/sandboxed_pages_apitest.cc
+++ b/chrome/browser/extensions/sandboxed_pages_apitest.cc
@@ -9,9 +9,11 @@
 #include "base/threading/thread_restrictions.h"
 #include "build/build_config.h"
 #include "chrome/browser/extensions/extension_apitest.h"
+#include "chrome/browser/profiles/profile.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "extensions/browser/process_map.h"
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension.h"
@@ -19,6 +21,7 @@
 #include "extensions/test/result_catcher.h"
 #include "extensions/test/test_extension_dir.h"
 #include "net/dns/mock_host_resolver.h"
+#include "services/network/public/cpp/web_sandbox_flags.h"
 #include "third_party/blink/public/common/features.h"
 
 static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -476,4 +479,104 @@
                         true, 1, "null");
 }
 
+// Verifies that MV3 sandboxed pages don't have access to extension messaging
+// APIs.
+IN_PROC_BROWSER_TEST_F(SandboxedPagesTest,
+                       ManifestV3MessagingBindingsWithheld) {
+  static constexpr char kManifest[] =
+      R"({
+           "name": "Sandboxed API exposure test",
+           "version": "0.1",
+           "manifest_version": 3,
+           "sandbox": { "pages": ["sandboxed.html"] }
+         })";
+  static constexpr char kSandboxedHtml[] =
+      R"(<html><body>Sandboxed Page</body></html>)";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  test_dir.WriteFile(FILE_PATH_LITERAL("sandboxed.html"), kSandboxedHtml);
+
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  content::WebContents* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(content::NavigateToURL(
+      web_contents, extension->GetResourceURL("sandboxed.html")));
+
+  EXPECT_EQ("undefined",
+            content::EvalJs(web_contents, "typeof chrome.runtime"));
+
+  // Sandboxed pages are hosted in a process that isn't tracked in the
+  // process map.
+  EXPECT_FALSE(ProcessMap::Get(profile())->Contains(
+      extension->id(),
+      web_contents->GetPrimaryMainFrame()->GetProcess()->GetID()));
+}
+
+// Pages that are sandboxed with the HTML5 `sandbox` attribute are treated
+// differently from pages specified in the "sandbox" attribute in the manifest.
+// These pages *do* get extension APIs.
+IN_PROC_BROWSER_TEST_F(SandboxedPagesTest,
+                       Html5SandboxedIframeMessagingBindingsExposed) {
+  // Load an extension with an HTML5-sandbox'd page that ping-pongs a message to
+  // its service worker.
+  static constexpr char kManifest[] =
+      R"({
+           "name": "HTML5 Sandboxed API exposure test",
+           "version": "0.1",
+           "manifest_version": 3,
+           "background": {"service_worker": "background.js"}
+         })";
+  static constexpr char kBackgroundJs[] =
+      R"(chrome.runtime.onMessage.addListener(
+             (message, sender, sendResponse) => {
+           sendResponse(`ack ${message}`);
+         });)";
+  static constexpr char kMainHtml[] =
+      R"(<html>
+           <body>
+             <h1>Main Page</h1>
+             <iframe sandbox="allow-scripts" src="child.html"></iframe>
+           </body>
+         </html>)";
+  static constexpr char kChildHtml[] =
+      R"(<html>
+           <body>Child Page</body>
+           <script src="child.js"></script>
+         </html>)";
+  static constexpr char kChildJs[] =
+      R"(chrome.runtime.sendMessage('hello', (response) => {
+           domAutomationController.send(response);
+         });)";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Privilege Escalation from Sandboxed Extension Pages via Messaging IPC Spoofing

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in extension messaging validation allows a compromised manifest-sandboxed extension page to spoof its source URL and extension ID in IPCs. This bypasses ’externally_connectable’ checks, allowing the sandboxed page to send messages that appear as trusted ‘internal’ communications to the extension’s privileged background script.

Affected files:

  • extensions/browser/api/messaging/message_service_bindings.cc
  • extensions/browser/extension_util.cc
  • extensions/browser/api/messaging/message_service.cc
  • extensions/common/api/messaging/messaging_endpoint.cc
  • extensions/browser/extension_frame_host.cc

Estimated timestamp from git blame: 2024-12-10

Description

Manifest-sandboxed extension pages (declared via the sandbox.pages manifest key) are designed to run untrusted content with limited privileges, completely isolated from extension APIs and background scripts (unless explicitly permitted via externally_connectable).

However, a potential vulnerability exists in the browser-side validation of extension messaging IPCs (ExtensionHostMsg_OpenChannelToExtension). A compromised sandboxed renderer can forge its source_endpoint.extension_id and source_url in the IPC payload. The browser incorrectly validates these spoofed values, categorizing the connection as a trusted internal message and bypassing security restrictions. This allows the sandboxed page to impersonate a privileged extension page (like background.js) to the background script, leading to privilege escalation.

Root Cause Analysis

The vulnerability stems from how extensions::MessageService validates the source of an incoming message in extensions/browser/api/messaging/message_service_bindings.cc.

When a sandboxed frame sends an OpenChannelToExtension IPC, the browser calls IsValidSourceUrl to validate the source_url provided by the renderer. If the spoofed source_url does not match the frame’s actual committed URL, the browser attempts to resolve it against the frame’s actual opaque origin (base_origin):

// In extensions/browser/api/messaging/message_service_bindings.cc
url::Origin source_url_origin = url::Origin::Resolve(source_url, base_origin);

The flaw lies in url::Origin::Resolve(url, base_origin). Because chrome-extension is a standard scheme, Origin::Create(url) successfully parses the spoofed URL (e.g., chrome-extension://<id>/background.js) into a non-opaque origin. Consequently, Origin::Resolve returns this non-opaque origin immediately, completely ignoring the sandboxed frame’s opaque base_origin.

Next, because the context is sandboxed, the browser derives a new opaque origin from the result:

if (IsPortContextSandboxed(process, source_context)) {
  source_url_origin = source_url_origin.DeriveNewOpaqueOrigin();
}

This creates an opaque origin whose precursor tuple matches the target extension. Finally, the browser checks if the process is allowed to host this origin:

auto* policy = content::ChildProcessSecurityPolicy::GetInstance();
if (!policy->HostsOrigin(process.GetDeprecatedID(), source_url_origin)) { ... }

In ChildProcessSecurityPolicyImpl::MatchesCommittedOrigin, the check succeeds because sandboxed extension pages are placed in a process locked to the extension’s site, and the precursor of the newly derived opaque origin matches the extension’s origin. The same flaw allows IsValidMessagingSource to validate a spoofed source_endpoint.extension_id.

Because the browser now believes the message originates from the extension itself, MessagingEndpoint::GetRelationship returns Relationship::kInternal. This completely bypasses the externally_connectable manifest key checks in MessageService::OpenChannelToExtension.

When the background script receives the connection (via runtime.onConnect or runtime.onMessage), the JavaScript sender object is populated with the spoofed data:

  • sender.id is the extension’s ID.
  • sender.url is the spoofed URL (e.g., chrome-extension://<id>/background.js).

If the background script relies on these fields to authenticate messages, the sandboxed page can execute privileged actions.

Potential Steps to Reproduce

Note: These are potential steps as our tooling cannot execute code.

  1. Install an extension that has a background script/service worker and a sandboxed page (e.g., sandbox.html). The background script must listen for internal messages and perform privileged actions based on them.
  2. Navigate to the sandboxed page.
  3. Assume an attacker compromises the sandboxed renderer process (e.g., via a standard renderer exploit).
  4. From the compromised renderer, use the mojom::LocalFrameHost interface to send an OpenChannelToExtension IPC via ExtensionFrameHost with the following parameters:
    • source_endpoint.type = kExtension
    • source_endpoint.extension_id = <extension_id>
    • target_id = <extension_id>
    • source_url = "chrome-extension://<extension_id>/background.js" (spoofed)
  5. The browser process validates these parameters. Due to the flaw in URL resolution and precursor matching, it treats the message as legitimate internal communication, bypassing externally_connectable checks.
  6. The extension’s background script receives the connection with the spoofed sender.url and sender.id, allowing the attacker to interact with the privileged background context.

Suggested Fix

  1. Strict URL Validation: IsValidSourceUrl should be hardened. If source_url does not match frame->GetLastCommittedURL(), and the frame is sandboxed (meaning its origin is opaque), it should not be possible to resolve a standard chrome-extension:// URL and have it accepted simply because the precursor matches.
  2. Origin Comparison: Instead of relying solely on policy->HostsOrigin (which only checks process locks and precursors for opaque origins), the messaging system should verify that the source_url provided by the renderer strictly corresponds to the frame’s actual committed origin or URL, especially for sandboxed contexts where the origin is opaque.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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