Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input Extensions
DescriptionInsufficient validation of untrusted input Extensions
ComponentChromium
Bug ClassLogic Error
Tracker516797143
Fix commitf89a7077fe13 (chromium/src) +114/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-11

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/extension_security_exploit_browsertest.cc
modified
if
extensions/browser/extension_function_dispatcher.cc
modified

Files Changed

  • chrome/browser/extensions/extension_security_exploit_browsertest.cc
  • extensions/browser/extension_function_dispatcher.cc
From f89a7077fe138ecce204f4d0f7537b26b7deafa1 Mon Sep 17 00:00:00 2001
From: Devlin Cronin <rdevlin.cronin@chromium.org>
Date: Fri, 05 Jun 2026 15:53:30 -0700
Subject: [PATCH] [Extensions] Check error pages in ExtensionFunctionDispatcher

ExtensionFunctionDispatcher initializes ExtensionFunction instances,
which are then used to determine if a call to a particular function is
allowed. It should be using trusted data when setting up these
functions.

Check if a RenderFrameHost hosts an error document -- if it does, don't
allow the extension function call. Error documents commit in the process
of their parent and have a misleading last-committed URL (it's the URL
of the target page, rather than chrome-error).

Drop all requests from error pages. We could theoretically instead set
the URL to something else, but there's (currently) no valid reason for
error pages to be calling extension APIs, so we can just ignore the
requests.

Bug: 516797143
Change-Id: I298f947629ea61856633729b93101d5c0f0698a9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7904593
Auto-Submit: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Devlin Cronin <rdevlin.cronin@chromium.org>
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1642655}
---

diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 5015d3de..3392529 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -28,6 +28,7 @@
 #include "content/public/common/content_client.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "content/public/test/test_navigation_observer.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"
@@ -56,11 +57,32 @@
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
-#include "third_party/blink/public/mojom/service_worker/service_worker_database.mojom-forward.h"
+#include "third_party/blink/public/mojom/service_worker/service_worker_database.mojom.h"
 #include "url/gurl.h"
 
 namespace extensions {
 
+namespace {
+
+// Helper to serve a page with CSP that blocks subframe navigations.
+std::unique_ptr<net::test_server::HttpResponse> HandleCspBlockRequest(
+    const net::test_server::HttpRequest& request) {
+  if (request.relative_url != "/csp_block.html") {
+    return nullptr;
+  }
+  auto response = std::make_unique<net::test_server::BasicHttpResponse>();
+  response->set_code(net::HTTP_OK);
+  response->set_content_type("text/html");
+  response->AddCustomHeader("Content-Security-Policy", "frame-src 'none'");
+  response->set_content(
+      "<html><body>"
+      "<iframe src=\"https://chromewebstore.google.com/\"></iframe>"
+      "</body></html>");
+  return response;
+}
+
+}  // namespace
+
 // ExtensionFrameHostInterceptor is a helper for:
 // - Intercepting mojom::LocalFrameHost method calls (e.g. methods
 //   that would normally be handled / implemented by ExtensionFrameHost).
@@ -280,6 +302,8 @@
 
     host_resolver()->AddRule("*", "127.0.0.1");
     content::SetupCrossSiteRedirector(embedded_test_server());
+    embedded_test_server()->RegisterRequestHandler(
+        base::BindRepeating(&HandleCspBlockRequest));
     ASSERT_TRUE(embedded_test_server()->Start());
   }
 
@@ -1843,4 +1867,79 @@
             kill_waiter.Wait());
 }
 
+// Verifies that error pages cannot use extension APIs that might be available
+// to their committed URLs. Regression test for crbug.com/516797143.
+IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
+                       ErrorPagesAreBlockedFromExtensionAPIs) {
+  const GURL webstore_url("https://chromewebstore.google.com/");
+
+  // Load a page that will include a subframe with a committed URL to the
+  // webstore, but will be blocked by CSP.
+  GURL test_page_url =
+      embedded_test_server()->GetURL("a.com", "/csp_block.html");
+  auto* web_contents = GetActiveWebContents();
+
+  content::TestNavigationObserver navigation_observer(web_contents);
+  // The navigation includes a frame being blocked (and thus committing to an
+  // error page), so we ignore the result of NavigateToURL().
+  std::ignore = NavigateToURL(web_contents, test_page_url);
+  navigation_observer.Wait();
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  EXPECT_EQ(main_frame->GetLastCommittedURL(), test_page_url);
+  content::RenderFrameHost* subframe = content::ChildFrameAt(main_frame, 0);
+  ASSERT_TRUE(subframe);
+
+  EXPECT_TRUE(subframe->IsErrorDocument());
+  EXPECT_EQ(subframe->GetLastCommittedURL(), webstore_url);
+
+  auto* extension_frame_host =
+      ExtensionWebContentsObserver::GetForWebContents(web_contents)
+          ->extension_frame_host_for_testing();
+
+  extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting(
+      subframe);
+
+  // Impersonate a request for an API call that the webstore is allowed to make.
+  mojom::RequestParamsPtr params = mojom::RequestParams::New();
+  params->name = "management.getAll";
+  params->arguments = base::ListValue();
+  params->extension_id = "";
+  params->source_url = webstore_url;
+  params->context_type = mojom::ContextType::kWebPage;
+  params->request_id = 1;
+  params->has_callback = true;
+  params->user_gesture = false;
+  params->worker_thread_id = kMainThreadId;
+  params->service_worker_version_id =
+      blink::mojom::kInvalidServiceWorkerVersionId;
+
+  base::RunLoop run_loop;
+  bool api_success = true;
+  std::string api_error;
+
+  extension_frame_host->Request(
+      std::move(params),
+      base::BindOnce(
+          [](base::OnceClosure quit_closure, bool* success_out,
+             std::string* error_out, bool success,
+             base::ListValue response_wrapper, const std::string& error,
+             mojom::ExtraResponseDataPtr extra_data) {
+            *success_out = success;
+            *error_out = error;
+            std::move(quit_closure).Run();
+          },
+          run_loop.QuitClosure(), &api_success, &api_error));
+
+  run_loop.Run();
+
+  // Clean up the target frame override.
+  extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting(
+      nullptr);
+
+  // We expect the request to have been blocked.
+  EXPECT_FALSE(api_success);
+  EXPECT_EQ(api_error, "Cannot call extension APIs from error pages.");
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/extension_function_dispatcher.cc b/extensions/browser/extension_function_dispatcher.cc
index a15ee6841..40f3215 100644
--- a/extensions/browser/extension_function_dispatcher.cc
+++ b/extensions/browser/extension_function_dispatcher.cc
@@ -281,6 +281,20 @@
 
   const GURL* render_frame_host_url = nullptr;
   if (render_frame_host) {
+    // Error pages commit with the target URL and in their parents' process, but
+    // aren't actually on the page indicated by the committed URL.
+    // Bail out in this case. Error pages don't use extension APIs.
+    // If we had perfect timing and no race conditions, this could be a sign of
+    // a bad message; however, it's possible a page commits to an error page
+    // after a legitimate message is sent.
+    if (render_frame_host->IsErrorDocument()) {
+      constexpr char kCannotUseExtensionAPIsInErrorPages[] =
+          "Cannot call extension APIs from error pages.";
+      ResponseCallbackOnError(std::move(callback),
+                              ExtensionFunction::ResponseType::kFailed,
+                              kCannotUseExtensionAPIsInErrorPages);
+      return;
+    }
     render_frame_host_url = &render_frame_host->GetLastCommittedURL();
     DCHECK_EQ(render_process_id,
               render_frame_host->GetProcess()->GetDeprecatedID());
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 5015d3de..3392529 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -28,6 +28,7 @@
 #include "content/public/common/content_client.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "content/public/test/test_navigation_observer.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"
@@ -56,11 +57,32 @@
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
-#include "third_party/blink/public/mojom/service_worker/service_worker_database.mojom-forward.h"
+#include "third_party/blink/public/mojom/service_worker/service_worker_database.mojom.h"
 #include "url/gurl.h"
 
 namespace extensions {
 
+namespace {
+
+// Helper to serve a page with CSP that blocks subframe navigations.
+std::unique_ptr<net::test_server::HttpResponse> HandleCspBlockRequest(
+    const net::test_server::HttpRequest& request) {
+  if (request.relative_url != "/csp_block.html") {
+    return nullptr;
+  }
+  auto response = std::make_unique<net::test_server::BasicHttpResponse>();
+  response->set_code(net::HTTP_OK);
+  response->set_content_type("text/html");
+  response->AddCustomHeader("Content-Security-Policy", "frame-src 'none'");
+  response->set_content(
+      "<html><body>"
+      "<iframe src=\"https://chromewebstore.google.com/\"></iframe>"
+      "</body></html>");
+  return response;
+}
+
+}  // namespace
+
 // ExtensionFrameHostInterceptor is a helper for:
 // - Intercepting mojom::LocalFrameHost method calls (e.g. methods
 //   that would normally be handled / implemented by ExtensionFrameHost).
@@ -280,6 +302,8 @@
 
     host_resolver()->AddRule("*", "127.0.0.1");
     content::SetupCrossSiteRedirector(embedded_test_server());
+    embedded_test_server()->RegisterRequestHandler(
+        base::BindRepeating(&HandleCspBlockRequest));
     ASSERT_TRUE(embedded_test_server()->Start());
   }
 
@@ -1843,4 +1867,79 @@
             kill_waiter.Wait());
 }
 
+// Verifies that error pages cannot use extension APIs that might be available
+// to their committed URLs. Regression test for crbug.com/516797143.
+IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
+                       ErrorPagesAreBlockedFromExtensionAPIs) {
+  const GURL webstore_url("https://chromewebstore.google.com/");
+
+  // Load a page that will include a subframe with a committed URL to the
+  // webstore, but will be blocked by CSP.
+  GURL test_page_url =
+      embedded_test_server()->GetURL("a.com", "/csp_block.html");
+  auto* web_contents = GetActiveWebContents();
+
+  content::TestNavigationObserver navigation_observer(web_contents);
+  // The navigation includes a frame being blocked (and thus committing to an
+  // error page), so we ignore the result of NavigateToURL().
+  std::ignore = NavigateToURL(web_contents, test_page_url);
+  navigation_observer.Wait();
+
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  EXPECT_EQ(main_frame->GetLastCommittedURL(), test_page_url);
+  content::RenderFrameHost* subframe = content::ChildFrameAt(main_frame, 0);
+  ASSERT_TRUE(subframe);
+
+  EXPECT_TRUE(subframe->IsErrorDocument());
+  EXPECT_EQ(subframe->GetLastCommittedURL(), webstore_url);
+
+  auto* extension_frame_host =
+      ExtensionWebContentsObserver::GetForWebContents(web_contents)
+          ->extension_frame_host_for_testing();
+
+  extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting(
+      subframe);
+
+  // Impersonate a request for an API call that the webstore is allowed to make.
+  mojom::RequestParamsPtr params = mojom::RequestParams::New();
+  params->name = "management.getAll";
+  params->arguments = base::ListValue();
+  params->extension_id = "";
+  params->source_url = webstore_url;
+  params->context_type = mojom::ContextType::kWebPage;
+  params->request_id = 1;
+  params->has_callback = true;
+  params->user_gesture = false;
+  params->worker_thread_id = kMainThreadId;
+  params->service_worker_version_id =
+      blink::mojom::kInvalidServiceWorkerVersionId;
+
+  base::RunLoop run_loop;
+  bool api_success = true;
+  std::string api_error;
+
+  extension_frame_host->Request(
+      std::move(params),
+      base::BindOnce(
+          [](base::OnceClosure quit_closure, bool* success_out,
+             std::string* error_out, bool success,
+             base::ListValue response_wrapper, const std::string& error,
+             mojom::ExtraResponseDataPtr extra_data) {
+            *success_out = success;
+            *error_out = error;
+            std::move(quit_closure).Run();
+          },
+          run_loop.QuitClosure(), &api_success, &api_error));
+
+  run_loop.Run();
+
+  // Clean up the target frame override.
+  extension_frame_host->receivers_for_testing().SetCurrentTargetFrameForTesting(
+      nullptr);
+
+  // We expect the request to have been blocked.
+  EXPECT_FALSE(api_success);
+  EXPECT_EQ(api_error, "Cannot call extension APIs from error pages.");
+}
+
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by vm...@google.com

Extension API privilege bypass via spoofed GetLastCommittedURL() on subframe error pages

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 potential logic bypass in ExtensionFunctionDispatcher allows a compromised renderer to access privileged Chrome Web Store APIs. When a subframe navigation to a restricted domain fails, it commits as an error page in the attacker’s renderer process while its last committed URL is updated to the blocked target. Since the dispatcher trusts this URL, the compromised process can invoke privileged APIs such as webstorePrivate and management.

Affected files:

  • extensions/browser/extension_function_dispatcher.cc

Estimated timestamp from git blame: 2021-09-15

Description

There is a potential logic bypass in ExtensionFunctionDispatcher where it determines the source_url() of an extension API request by querying RenderFrameHost::GetLastCommittedURL() without verifying if the frame hosts an error document (via IsErrorDocument()).

When a subframe navigation fails (for instance, when blocked by a Content Security Policy like frame-src 'none'), it commits as an error page. Because subframe error pages are not isolated into their own process in Chromium (governed by ChromeContentBrowserClient::ShouldIsolateErrorPage), they commit within the initiator’s (the attacker’s) renderer process. However, GetLastCommittedURL() is still updated to the failed cross-origin target URL (e.g., https://chromewebstore.google.com/).

Since ExtensionFunctionDispatcher trusts this URL, a compromised renderer can bind extensions::mojom::LocalFrameHost for the subframe and dispatch extension function requests. The browser-side permission checks (HasPermission()) evaluate availability against the spoofed source_url(), allowing the compromised renderer to execute privileged APIs restricted to the webstore domain (such as webstorePrivate and management).

Vulnerable Code Path

In extensions/browser/extension_function_dispatcher.cc:

const GURL* render_frame_host_url = nullptr;
if (render_frame_host) {
  render_frame_host_url = &render_frame_host->GetLastCommittedURL();
  DCHECK_EQ(render_process_id,
            render_frame_host->GetProcess()->GetDeprecatedID());
}
...
if (is_worker_request) {
  function->set_source_url(params_without_args.source_url);
} else {
  DCHECK(render_frame_host_url);
  function->set_source_url(*render_frame_host_url);
}

And in content/browser/renderer_host/render_frame_host_impl.cc:

void RenderFrameHostImpl::DidNavigate(
    const mojom::DidCommitProvisionalLoadParams& params,
    NavigationRequest* navigation_request,
    bool was_within_same_document) {
  ...
  // The URL is set regardless of whether it's for a net error or not.
  SetLastCommittedUrl(params.url);

Potential Steps to Reproduce

Note: These are suggested/potential steps; our tooling does not have the ability to run live code.

  1. Assume a compromised renderer process (renderer RCE).
  2. Set up a subframe (<iframe>) inside the compromised page and restrict it using a CSP header such as frame-src 'none' to guarantee a navigation failure.
  3. Navigate the subframe to https://chromewebstore.google.com/.
  4. The subframe navigation is blocked and commits a local error page within the attacker’s renderer process. The subframe RenderFrameHost’s GetLastCommittedURL() is set to https://chromewebstore.google.com/.
  5. From the compromised renderer process, request to bind the extensions::mojom::LocalFrameHost interface on the subframe’s channel.
  6. Issue a Mojo Request IPC targeting management.setEnabled to silently disable any target extension ID.
  7. The browser-side dispatcher validates the request, sets the source_url to https://chromewebstore.google.com/, passes permission checks, and disables the target extension.

Suggested Fix

ExtensionFunctionDispatcher should reject requests originating from frames where IsErrorDocument() is true, or ensure that privilege validation is evaluated using GetLastCommittedOrigin() (which returns an opaque origin for error pages) rather than relying on GetLastCommittedURL().

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