CVE-2026-113113
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
GetMessageTextFragmentcomponents/custom_handlers/register_protocol_handler_permission_request.cc |
modified | |
ifcomponents/custom_handlers/register_protocol_handler_permission_request.cc |
modified | |
GURLcomponents/custom_handlers/register_protocol_handler_permission_request.h |
modified | |
Origincomponents/custom_handlers/register_protocol_handler_permission_request.h |
modified | |
ProtocolHandlerRegistrycomponents/custom_handlers/register_protocol_handler_permission_request.h |
modified | |
PermissionPromptWaiterchrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc |
modified | |
ifchrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/views/permissions/permission_prompt_bubble_base_view_browsertest.cccomponents/custom_handlers/register_protocol_handler_permission_request.hchrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cccomponents/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT_CROSS_ORIGIN.png.sha1content/browser/web_contents/web_contents_impl_unittest.ccchrome/browser/custom_handlers/protocol_handler_registry_browsertest.ccthird_party/blink/renderer/modules/navigatorcontentutils/navigator_content_utils.cccomponents/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT_CROSS_ORIGIN.png.sha1content/browser/web_contents/web_contents_impl.cccomponents/custom_handlers/register_protocol_handler_permission_request.cccomponents/protocol_handler_strings.grdp
Patch
From 81b413f2da93c5a908e903bfc7e8fb9c56a53499 Mon Sep 17 00:00:00 2001 From: Devlin Cronin <[email protected]> Date: Thu, 17 Sep 2026 10:27:53 -0700 Subject: [PATCH] [Extensions] Block protocol handler registration to non-http(s) The `navigator.registerProtocolHandler()` API allows pages to register custom protocol handlers. For standard web pages, the handler URL is strictly required to be same-origin with the registering document. For contexts with elevated security levels (such as extensions), this same-origin restriction was relaxed via `kUntrustedOrigins` to allow registering external web endpoints. However, elevated security levels also allow custom handler URL schemes like `chrome-extension://` and `isolated-app://`. Because the relaxation was not restricted by scheme, an extension could register a protocol handler targeting an endpoint belonging to a *different* extension. In addition, the permission prompt attributed the request to the target URL rather than the requesting frame's origin, which could mislead the user into believing the prompt was initiated by the target origin. This CL fixes both issues: - Enforces that cross-origin protocol handler registration is only permitted for HTTP(S) handler URLs. Handler URLs with non-HTTP(S) schemes (such as chrome-extension://) must always be same-origin with the requesting frame, in both renderer and browser checks. - Explicitly rejects opaque requesting and handler origins to prevent sandboxed contexts from registering handlers. - Attributes the permission prompt to the initiating frame's origin rather than the target URL. - Updates the confirmation prompt phrasing when registering a cross-origin handler to include the target hostname (e.g., "Open email links through example.com"). Adds browser tests verifying that cross-extension handler URLs are rejected and that permission prompts attribute the requesting origin as well as new UI tests for the updated prompts. Bug: 495899355 Change-Id: Ia8b017a244057999fba26936ebf88e4205afb1d3 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8393905 Reviewed-by: Nico Weber <[email protected]> Reviewed-by: Andy Paicu <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Commit-Queue: Devlin Cronin <[email protected]> Reviewed-by: Javier Fernandez <[email protected]> Reviewed-by: Darryl James <[email protected]> Cr-Commit-Position: refs/heads/main@{#1699943} --- diff --git a/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc b/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc index 2cbcd19..4c8252d1 100644 --- a/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc +++ b/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc @@ -1384,10 +1384,17 @@ return; } + // The permission prompt must attribute the request to the initiator frame + // that called navigator.registerProtocolHandler, rather than the target + // handler URL. For web pages these are always same-origin, but extensions + // can register cross-origin HTTPS endpoints; attributing the prompt to the + // target URL would allow an extension to spoof the prompt as coming from + // the target origin. permission_request_manager->AddRequest( rfh, std::make_unique< custom_handlers::RegisterProtocolHandlerPermissionRequest>( - registry, handler, url, std::move(*blocker))); + registry, handler, rfh->GetLastCommittedOrigin(), + std::move(*blocker))); } } diff --git a/components/custom_handlers/register_protocol_handler_permission_request.cc b/components/custom_handlers/register_protocol_handler_permission_request.cc index d9db1ef5..07ff391 100644 --- a/components/custom_handlers/register_protocol_handler_permission_request.cc +++ b/components/custom_handlers/register_protocol_handler_permission_request.cc @@ -19,6 +19,7 @@ #include "components/strings/grit/components_strings.h" #include "ui/base/l10n/l10n_util.h" #include "url/gurl.h" +#include "url/origin.h" namespace custom_handlers { @@ -26,13 +27,13 @@ RegisterProtocolHandlerPermissionRequest( custom_handlers::ProtocolHandlerRegistry* registry, const ProtocolHandler& handler, - GURL url, + const url::Origin& requesting_origin, base::ScopedClosureRunner fullscreen_block) : PermissionRequest( std::make_unique<permissions::PermissionRequestData>( permissions::RequestType::kRegisterProtocolHandler, /*user_gesture=*/false, - url.DeprecatedGetOriginAsURL()), + requesting_origin.GetURL()), base::BindRepeating( &RegisterProtocolHandlerPermissionRequest::PermissionDecided, base::Unretained(this))), @@ -57,6 +58,21 @@ std::u16string RegisterProtocolHandlerPermissionRequest::GetMessageTextFragment() const { ProtocolHandler old_handler = registry_->GetHandlerFor(handler_.protocol()); + bool is_cross_origin = + !url::IsSameOriginWith(handler_.url(), requesting_origin()); + if (is_cross_origin) { + return old_handler.IsEmpty() + ? l10n_util::GetStringFUTF16( + IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT_CROSS_ORIGIN, + handler_.GetProtocolDisplayName(), + base::UTF8ToUTF16(handler_.url().host())) + : l10n_util::GetStringFUTF16( + IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT_CROSS_ORIGIN, + handler_.GetProtocolDisplayName(), + base::UTF8ToUTF16(handler_.url().host()), + base::UTF8ToUTF16(old_handler.url().host())); + } + return old_handler.IsEmpty() ? l10n_util::GetStringFUTF16( IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT, diff --git a/components/custom_handlers/register_protocol_handler_permission_request.h b/components/custom_handlers/register_protocol_handler_permission_request.h index dc5cbea..cb50181 100644 --- a/components/custom_handlers/register_protocol_handler_permission_request.h +++ b/components/custom_handlers/register_protocol_handler_permission_request.h @@ -16,7 +16,10 @@ struct PermissionPromptDecision; } // namespace permissions -class GURL; +namespace url { +class Origin; +} // namespace url + namespace custom_handlers { class ProtocolHandlerRegistry; @@ -28,7 +31,7 @@ RegisterProtocolHandlerPermissionRequest( custom_handlers::ProtocolHandlerRegistry* registry, const ProtocolHandler& handler, - GURL url, + const url::Origin& requesting_origin, base::ScopedClosureRunner fullscreen_block); RegisterProtocolHandlerPermissionRequest( diff --git a/components/protocol_handler_strings.grdp b/components/protocol_handler_strings.grdp index 7f0a63d8..9c8e4a7 100644 --- a/components/protocol_handler_strings.grdp +++ b/components/protocol_handler_strings.grdp @@ -20,9 +20,15 @@ <message name="IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT" desc="The permission fragment to display when asking a user to confirm the registration of a protocol handler in a permission bubble. Follows a prompt 'This site would like to:'."> Open <ph name="PROTOCOL">$1<ex>search</ex></ph> links </message> + <message name="IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT_CROSS_ORIGIN" desc="The permission fragment to display when asking a user to confirm the registration of a cross-origin protocol handler in a permission bubble. Follows a prompt 'This site would like to:' or '[Extension] would like to:'."> + Open <ph name="PROTOCOL">$1<ex>search</ex></ph> links through <ph name="HANDLER_HOSTNAME">$2<ex>google.com</ex></ph> + </message> <message name="IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT" desc="The permission fragment to display when asking a user to confirm the registration of a protocol handler. Follows a prompt 'This site would like to:'."> Open <ph name="PROTOCOL">$1<ex>search</ex></ph> links instead of <ph name="REPLACED_HANDLER_TITLE">$2<ex>Elgoog Search</ex></ph> </message> + <message name="IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT_CROSS_ORIGIN" desc="The permission fragment to display when asking a user to confirm the registration of a cross-origin protocol handler in a permission bubble when there is already a registered handler. Follows a prompt 'This site would like to:' or '[Extension] would like to:'."> + Open <ph name="PROTOCOL">$1<ex>search</ex></ph> links through <ph name="HANDLER_HOSTNAME">$2<ex>google.com</ex></ph> instead of <ph name="REPLACED_HANDLER_TITLE">$3<ex>Elgoog Search</ex></ph> + </message> <message name="IDS_REGISTER_PROTOCOL_HANDLER_ACCEPT" desc="Text to show for the accept button for the register protocol handler request infobar."> Allow </message> diff --git a/components/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT_CROSS_ORIGIN.png.sha1 b/components/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT_CROSS_ORIGIN.png.sha1 new file mode 100644 index 0000000..97450e7 --- /dev/null +++ b/components/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_FRAGMENT_CROSS_ORIGIN.png.sha1 @@ -0,0 +1 @@ +73187c3711364edff54b6725383150768f67d618 \ No newline at end of file diff --git a/components/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT_CROSS_ORIGIN.png.sha1 b/components/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT_CROSS_ORIGIN.png.sha1 new file mode 100644 index 0000000..b798edc --- /dev/null +++ b/components/protocol_handler_strings_grdp/IDS_REGISTER_PROTOCOL_HANDLER_CONFIRM_REPLACE_FRAGMENT_CROSS_ORIGIN.png.sha1 @@ -0,0 +1 @@ +f41bbf3720621531a0b4448dd2124a42f9fc0abb \ No newline at end of file diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc index ba69b6a3..0689e63e 100644 --- a/content/browser/web_contents/web_contents_impl.cc +++ b/content/browser/web_contents/web_contents_impl.cc @@ -415,11 +415,22 @@ return false; } url::Origin url_origin = url::Origin::Create(url); - if (url_origin.opaque()) { + // Opaque handler URLs (e.g. data:, blob:) and opaque requesting origins (e.g. + // sandboxed iframes) are not allowed to register protocol handlers. In the + // case of web pages, the requesting origin and target origin have to be the + // same; however, this is not true for other contexts (e.g. extensions). + // Checking both `origin.opaque()` ensures that opaque origins cannot register + // handlers. + if (url_origin.opaque() || origin.opaque()) { return false; } - if (security_level < blink::ProtocolHandlerSecurityLevel::kUntrustedOrigins && - !origin.IsSameOriginWith(url)) { + // At elevated levels the same-origin requirement may be relaxed only for + // HTTP(S) handler URLs; non-HTTP(S) handler URLs (e.g. extension or + // isolated-app schemes) must always be same-origin with the requester. + if (!origin.IsSameOriginWith(url) && + (security_level < + blink::ProtocolHandlerSecurityLevel::kUntrustedOrigins || + !url.SchemeIsHTTPOrHTTPS())) { return false; } diff --git a/third_party/blink/renderer/modules/navigatorcontentutils/navigator_content_utils.cc b/third_party/blink/renderer/modules/navigatorcontentutils/navigator_content_utils.cc index 153fde1e..65b09fab 100644 --- a/third_party/blink/renderer/modules/navigatorcontentutils/navigator_content_utils.cc +++ b/third_party/blink/renderer/modules/navigatorcontentutils/navigator_content_utils.cc @@ -70,8 +70,11 @@ } // The specification says that the API throws SecurityError exception if the - // URL's origin differs from the window's origin. - if (security_level < ProtocolHandlerSecurityLevel::kUntrustedOrigins && + // URL's origin differs from the window's origin. At elevated security levels, + // the same-origin requirement may be relaxed, but only for HTTP(S) handler + // URLs. + if ((security_level < ProtocolHandlerSecurityLevel::kUntrustedOrigins || + !full_url.ProtocolIsInHttpFamily()) && !window.GetSecurityOrigin()->CanRequest(full_url)) { error_message = "Can only register custom handler in the document's origin."; diff --git a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc index fe86f27..0548b91 100644 --- a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc +++ b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc @@ -28,7 +28,9 @@ #include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h" #include "chrome/test/base/in_process_browser_test.h" #include "chrome/test/base/ui_test_utils.h" +#include "chrome/test/permissions/permission_request_manager_test_api.h" #include "components/custom_handlers/protocol_handler.h" +#include "components/permissions/permission_request.h" #include "components/permissions/permission_request_manager.h" #include "content/public/browser/navigation_controller.h" #include "content/public/browser/navigation_entry.h" @@ -43,6 +45,8 @@ #include "net/test/embedded_test_server/embedded_test_server.h" #include "third_party/blink/public/mojom/context_menu/context_menu.mojom.h" #include "third_party/blink/public/mojom/frame/fullscreen.mojom.h" +#include "ui/views/widget/widget.h" +#include "ui/views/widget/widget_delegate.h" #include "url/url_util.h" #if BUILDFLAG(IS_MAC) @@ -447,6 +451,187 @@ EXPECT_TRUE(registry->GetExtensionProtocolHandlers().empty()); } +// An extension may register a handler URL inside its own origin, but must not +// be able to register a handler URL that belongs to a different extension. +IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest, + CrossExtensionHandlerURLRejected) { +#if BUILDFLAG(IS_MAC) + ASSERT_TRUE(test::RegisterAppWithLaunchServices()); +#endif + + const extensions::Extension* requesting_extension = + LoadExtension(test_data_dir_.AppendASCII("protocol_handler")); + ASSERT_NE(nullptr, requesting_extension); + const extensions::Extension* other_extension = + LoadExtension(test_data_dir_.AppendASCII("simple_with_file")); + ASSERT_NE(nullptr, other_extension); + EXPECT_NE(requesting_extension->id(), other_extension->id()); + + ASSERT_TRUE(ui_test_utils::NavigateToURL( + browser(), requesting_extension->GetResourceURL("test.html"))); + WebContents* web_contents = + browser()->tab_strip_model()->GetActiveWebContents(); + + ProtocolHandlerRegistry* registry = + ProtocolHandlerRegistryFactory::GetForBrowserContext( + browser()->GetProfile()); + registry->SetRphRegistrationMode( + custom_handlers::RphRegistrationMode::kAutoAccept); + + // A handler URL in a different extension's origin must be rejected. + std::string other_extension_handler_url = + other_extension->GetResourceURL("file.html").spec() + "?q=%s"; + EXPECT_THAT( + content::EvalJs( + web_contents, + content::JsReplace("navigator.registerProtocolHandler('mailto', $1)", + other_extension_handler_url)), + content::EvalJsResult::ErrorIs(testing::HasSubstr("SecurityError"))); + + // A handler URL in the requesting extension's own origin is still accepted. + EXPECT_TRUE(content::ExecJs( + web_contents, + "navigator.registerProtocolHandler('mailto', 'test.html?%s')")); +} + +class PermissionPromptWaiter + : public permissions::PermissionRequestManager::Observer { + public: + explicit PermissionPromptWaiter( + permissions::PermissionRequestManager* manager) + : manager_(manager) { + observation_.Observe(manager); + } + ~PermissionPromptWaiter() override = default; + + void Wait() { + if (prompt_added_) { + return; + } + run_loop_.Run(); + } + + void OnPromptAdded() override { + prompt_added_ = true; + run_loop_.Quit(); + } + + private: + raw_ptr<permissions::PermissionRequestManager> manager_; + base::ScopedObservation<permissions::PermissionRequestManager, + permissions::PermissionRequestManager::Observer> + observation_{this}; + base::RunLoop run_loop_; + bool prompt_added_ = false; +}; + +// Verify that when an extension registers a custom protocol handler pointing +// to a cross-origin HTTP(S) URL as a new registration, the permission prompt UI +// attributes the request to the extension's own origin and displays the target +// host in the prompt phrasing. +IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest, + PromptUI_NewHandler) { +#if BUILDFLAG(IS_MAC) + ASSERT_TRUE(test::RegisterAppWithLaunchServices()); +#endif + + const extensions::Extension* extension = + LoadExtension(test_data_dir_.AppendASCII("protocol_handler")); + ASSERT_NE(nullptr, extension); + + ASSERT_TRUE(ui_test_utils::NavigateToURL( + browser(), extension->GetResourceURL("test.html"))); + WebContents* web_contents = + browser()->tab_strip_model()->GetActiveWebContents(); + + permissions::PermissionRequestManager* permission_request_manager = + permissions::PermissionRequestManager::FromWebContents(web_contents); + ASSERT_NE(nullptr, permission_request_manager); + + PermissionPromptWaiter waiter(permission_request_manager); + + // Do not set auto-accept mode so that the request is displayed in the UI. + EXPECT_TRUE( + content::ExecJs(web_contents, + "navigator.registerProtocolHandler('geo', " + "'https://example.com/handler?q=%s', 'Geo Handler');")); + + waiter.Wait(); + ASSERT_EQ(1u, permission_request_manager->Requests().size()); + EXPECT_EQ(extension->origin().GetURL(), + permission_request_manager->Requests()[0]->requesting_origin()); + EXPECT_EQ( + u"Open geo links through example.com", + permission_request_manager->Requests()[0]->GetMessageTextFragment()); + + test::PermissionRequestManagerTestApi test_api(permission_request_manager); + views::Widget* prompt_window = test_api.GetPromptWindow(); + ASSERT_TRUE(prompt_window); + EXPECT_TRUE(prompt_window->IsVisible()); + EXPECT_THAT(base::UTF16ToUTF8( + prompt_window->widget_delegate()->GetAccessibleWindowTitle()), + testing::HasSubstr("Open geo links through example.com")); +} + +// Verify that when an extension registers a custom protocol handler pointing +// to a cross-origin HTTP(S) URL that replaces an existing handler, the +// permission prompt UI attributes the request to the extension's own origin and +// displays both the target host and the old handler host in the prompt +// phrasing. +IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest, + PromptUI_ReplaceHandler) { +#if BUILDFLAG(IS_MAC) + ASSERT_TRUE(test::RegisterAppWithLaunchServices()); +#endif + + // Pre-register an existing handler for geo. + custom_handlers::ProtocolHandlerRegistry* registry = + ProtocolHandlerRegistryFactory::GetForBrowserContext( + browser()->GetProfile()); + custom_handlers::ProtocolHandler old_handler = + custom_handlers::ProtocolHandler::CreateProtocolHandler( + "geo", GURL("https://old.com/handler?q=%s")); + registry->OnAcceptRegisterProtocolHandler(old_handler); + + const extensions::Extension* extension = ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
index fe86f27..0548b91 100644
--- a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
+++ b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
@@ -28,7 +28,9 @@
#include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
+#include "chrome/test/permissions/permission_request_manager_test_api.h"
#include "components/custom_handlers/protocol_handler.h"
+#include "components/permissions/permission_request.h"
#include "components/permissions/permission_request_manager.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/navigation_entry.h"
@@ -43,6 +45,8 @@
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "third_party/blink/public/mojom/context_menu/context_menu.mojom.h"
#include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
+#include "ui/views/widget/widget.h"
+#include "ui/views/widget/widget_delegate.h"
#include "url/url_util.h"
#if BUILDFLAG(IS_MAC)
@@ -447,6 +451,187 @@
EXPECT_TRUE(registry->GetExtensionProtocolHandlers().empty());
}
+// An extension may register a handler URL inside its own origin, but must not
+// be able to register a handler URL that belongs to a different extension.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+ CrossExtensionHandlerURLRejected) {
+#if BUILDFLAG(IS_MAC)
+ ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+
+ const extensions::Extension* requesting_extension =
+ LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+ ASSERT_NE(nullptr, requesting_extension);
+ const extensions::Extension* other_extension =
+ LoadExtension(test_data_dir_.AppendASCII("simple_with_file"));
+ ASSERT_NE(nullptr, other_extension);
+ EXPECT_NE(requesting_extension->id(), other_extension->id());
+
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), requesting_extension->GetResourceURL("test.html")));
+ WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ ProtocolHandlerRegistry* registry =
+ ProtocolHandlerRegistryFactory::GetForBrowserContext(
+ browser()->GetProfile());
+ registry->SetRphRegistrationMode(
+ custom_handlers::RphRegistrationMode::kAutoAccept);
+
+ // A handler URL in a different extension's origin must be rejected.
+ std::string other_extension_handler_url =
+ other_extension->GetResourceURL("file.html").spec() + "?q=%s";
+ EXPECT_THAT(
+ content::EvalJs(
+ web_contents,
+ content::JsReplace("navigator.registerProtocolHandler('mailto', $1)",
+ other_extension_handler_url)),
+ content::EvalJsResult::ErrorIs(testing::HasSubstr("SecurityError")));
+
+ // A handler URL in the requesting extension's own origin is still accepted.
+ EXPECT_TRUE(content::ExecJs(
+ web_contents,
+ "navigator.registerProtocolHandler('mailto', 'test.html?%s')"));
+}
+
+class PermissionPromptWaiter
+ : public permissions::PermissionRequestManager::Observer {
+ public:
+ explicit PermissionPromptWaiter(
+ permissions::PermissionRequestManager* manager)
+ : manager_(manager) {
+ observation_.Observe(manager);
+ }
+ ~PermissionPromptWaiter() override = default;
+
+ void Wait() {
+ if (prompt_added_) {
+ return;
+ }
+ run_loop_.Run();
+ }
+
+ void OnPromptAdded() override {
+ prompt_added_ = true;
+ run_loop_.Quit();
+ }
+
+ private:
+ raw_ptr<permissions::PermissionRequestManager> manager_;
+ base::ScopedObservation<permissions::PermissionRequestManager,
+ permissions::PermissionRequestManager::Observer>
+ observation_{this};
+ base::RunLoop run_loop_;
+ bool prompt_added_ = false;
+};
+
+// Verify that when an extension registers a custom protocol handler pointing
+// to a cross-origin HTTP(S) URL as a new registration, the permission prompt UI
+// attributes the request to the extension's own origin and displays the target
+// host in the prompt phrasing.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+ PromptUI_NewHandler) {
+#if BUILDFLAG(IS_MAC)
+ ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+
+ const extensions::Extension* extension =
+ LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+ ASSERT_NE(nullptr, extension);
+
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), extension->GetResourceURL("test.html")));
+ WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ permissions::PermissionRequestManager* permission_request_manager =
+ permissions::PermissionRequestManager::FromWebContents(web_contents);
+ ASSERT_NE(nullptr, permission_request_manager);
+
+ PermissionPromptWaiter waiter(permission_request_manager);
+
+ // Do not set auto-accept mode so that the request is displayed in the UI.
+ EXPECT_TRUE(
+ content::ExecJs(web_contents,
+ "navigator.registerProtocolHandler('geo', "
+ "'https://example.com/handler?q=%s', 'Geo Handler');"));
+
+ waiter.Wait();
+ ASSERT_EQ(1u, permission_request_manager->Requests().size());
+ EXPECT_EQ(extension->origin().GetURL(),
+ permission_request_manager->Requests()[0]->requesting_origin());
+ EXPECT_EQ(
+ u"Open geo links through example.com",
+ permission_request_manager->Requests()[0]->GetMessageTextFragment());
+
+ test::PermissionRequestManagerTestApi test_api(permission_request_manager);
+ views::Widget* prompt_window = test_api.GetPromptWindow();
+ ASSERT_TRUE(prompt_window);
+ EXPECT_TRUE(prompt_window->IsVisible());
+ EXPECT_THAT(base::UTF16ToUTF8(
+ prompt_window->widget_delegate()->GetAccessibleWindowTitle()),
+ testing::HasSubstr("Open geo links through example.com"));
+}
+
+// Verify that when an extension registers a custom protocol handler pointing
+// to a cross-origin HTTP(S) URL that replaces an existing handler, the
+// permission prompt UI attributes the request to the extension's own origin and
+// displays both the target host and the old handler host in the prompt
+// phrasing.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+ PromptUI_ReplaceHandler) {
+#if BUILDFLAG(IS_MAC)
+ ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+
+ // Pre-register an existing handler for geo.
+ custom_handlers::ProtocolHandlerRegistry* registry =
+ ProtocolHandlerRegistryFactory::GetForBrowserContext(
+ browser()->GetProfile());
+ custom_handlers::ProtocolHandler old_handler =
+ custom_handlers::ProtocolHandler::CreateProtocolHandler(
+ "geo", GURL("https://old.com/handler?q=%s"));
+ registry->OnAcceptRegisterProtocolHandler(old_handler);
+
+ const extensions::Extension* extension =
+ LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+ ASSERT_NE(nullptr, extension);
+
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), extension->GetResourceURL("test.html")));
+ WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ permissions::PermissionRequestManager* permission_request_manager =
+ permissions::PermissionRequestManager::FromWebContents(web_contents);
+ ASSERT_NE(nullptr, permission_request_manager);
+
+ PermissionPromptWaiter waiter(permission_request_manager);
+
+ // Do not set auto-accept mode so that the request is displayed in the UI.
+ EXPECT_TRUE(
+ content::ExecJs(web_contents,
+ "navigator.registerProtocolHandler('geo', "
+ "'https://example.com/handler?q=%s', 'Geo Handler');"));
+
+ waiter.Wait();
+ ASSERT_EQ(1u, permission_request_manager->Requests().size());
+ EXPECT_EQ(extension->origin().GetURL(),
+ permission_request_manager->Requests()[0]->requesting_origin());
+ EXPECT_EQ(
+ u"Open geo links through example.com instead of old.com",
+ permission_request_manager->Requests()[0]->GetMessageTextFragment());
+
+ test::PermissionRequestManagerTestApi test_api(permission_request_manager);
+ views::Widget* prompt_window = test_api.GetPromptWindow();
+ ASSERT_TRUE(prompt_window);
+ EXPECT_TRUE(prompt_window->IsVisible());
+ EXPECT_THAT(base::UTF16ToUTF8(
+ prompt_window->widget_delegate()->GetAccessibleWindowTitle()),
+ testing::HasSubstr(
+ "Open geo links through example.com instead of old.com"));
+}
+
IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest, Basic) {
#if BUILDFLAG(IS_MAC)
ASSERT_TRUE(test::RegisterAppWithLaunchServices());
diff --git a/chrome/browser/ui/views/permissions/permission_prompt_bubble_base_view_browsertest.cc b/chrome/browser/ui/views/permissions/permission_prompt_bubble_base_view_browsertest.cc
index c800755b..70b778c 100644
--- a/chrome/browser/ui/views/permissions/permission_prompt_bubble_base_view_browsertest.cc
+++ b/chrome/browser/ui/views/permissions/permission_prompt_bubble_base_view_browsertest.cc
@@ -50,6 +50,7 @@
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/cursor_utils.h"
#include "content/public/test/test_utils.h"
+#include "extensions/buildflags/buildflags.h"
#include "net/dns/mock_host_resolver.h"
#include "ui/base/cursor/cursor.h"
#include "ui/base/cursor/mojom/cursor_type.mojom-shared.h"
@@ -58,6 +59,13 @@
#include "ui/views/controls/styled_label.h"
#include "ui/views/test/ax_event_counter.h"
#include "url/gurl.h"
+#include "url/origin.h"
+
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+#include "chrome/browser/extensions/chrome_test_extension_loader.h"
+#include "extensions/common/extension.h"
+#include "extensions/test/test_extension_dir.h"
+#endif
// To run the pixel tests of this file run: browser_tests
// --gtest_filter=BrowserUiTest.Invoke --test-launcher-interactive
@@ -139,20 +147,44 @@
private:
std::unique_ptr<custom_handlers::RegisterProtocolHandlerPermissionRequest>
- MakeRegisterProtocolHandlerRequest() {
+ MakeRegisterProtocolHandlerRequest(const GURL& handler_url,
+ const url::Origin& requesting_origin) {
std::string protocol = "mailto";
custom_handlers::ProtocolHandler handler =
custom_handlers::ProtocolHandler::CreateProtocolHandler(protocol,
- GetTestUrl());
+ handler_url);
custom_handlers::ProtocolHandlerRegistry* registry =
ProtocolHandlerRegistryFactory::GetForBrowserContext(
browser()->GetProfile());
// Deleted in RegisterProtocolHandlerPermissionRequest::RequestFinished().
return std::make_unique<
custom_handlers::RegisterProtocolHandlerPermissionRequest>(
- registry, handler, GetTestUrl(), base::ScopedClosureRunner());
+ registry, handler, requesting_origin, base::ScopedClosureRunner());
}
+ std::unique_ptr<custom_handlers::RegisterProtocolHandlerPermissionRequest>
+ MakeRegisterProtocolHandlerRequest() {
+ return MakeRegisterProtocolHandlerRequest(
+ GetTestUrl(), url::Origin::Create(GetTestUrl()));
+ }
+
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+ url::Origin GetTestExtensionOrigin() {
+ if (!test_extension_) {
+ test_extension_dir_.WriteManifest(
+ R"({
+ "name": "Test Extension",
+ "version": "0.1",
+ "manifest_version": 3
+ })");
+ test_extension_ =
+ extensions::ChromeTestExtensionLoader(browser()->GetProfile())
+ .LoadExtension(test_extension_dir_.UnpackedPath());
+ }
+ return test_extension_ ? test_extension_->origin() : url::Origin();
+ }
+#endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+
void AddRequestForContentSetting(const std::string& name) {
constexpr const char* kMultipleName = "multiple";
struct NameType {
@@ -166,6 +198,12 @@
{"mic", ContentSettingsType::MEDIASTREAM_MIC},
{"camera", ContentSettingsType::MEDIASTREAM_CAMERA},
{"protocol_handlers", ContentSettingsType::PROTOCOL_HANDLERS},
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+ {"extension_protocol_handlers_cross_origin",
+ ContentSettingsType::PROTOCOL_HANDLERS},
+ {"extension_protocol_handlers_cross_origin_replace",
+ ContentSettingsType::PROTOCOL_HANDLERS},
+#endif
{"midi", ContentSettingsType::MIDI_SYSEX},
{"storage_access", ContentSettingsType::STORAGE_ACCESS},
{"downloads", ContentSettingsType::AUTOMATIC_DOWNLOADS},
@@ -189,6 +227,33 @@
... (truncated)
Original Bug Report
Cross-extension protocol handler registration bypass and UI spoofing
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic flaw allows a malicious extension to register a custom protocol handler pointing to a different extension’s URL. The resulting permission prompt uses the target URL’s origin, spoofing the request to appear as if it came from the victim extension. An attacker can then trigger browser-initiated navigations to the victim extension with attacker-controlled parameters, bypassing security throttles.
Affected files:
content/browser/web_contents/web_contents_impl.ccchrome/browser/ui/browser.ccthird_party/blink/common/custom_handlers/protocol_handler_utils.cccomponents/custom_handlers/register_protocol_handler_permission_request.ccextensions/browser/extension_navigation_throttle.cc
Estimated timestamp from git blame: 2025-05-05
Note: This report was generated by our setup. The steps below are potential and have been determined through static code analysis; a working proof-of-concept has not been executed.
Vulnerability Details
There is a logic flaw in how Chrome handles navigator.registerProtocolHandler when called by privileged extensions, leading to a same-origin policy bypass, UI spoofing, and a cross-extension navigation primitive.
1. Same-Origin Bypass in WebContentsImpl
When a privileged extension registers a protocol handler, its security level is evaluated as blink::ProtocolHandlerSecurityLevel::kExtensionFeatures (integer value 3). In WebContentsImpl::AreValidRegisterProtocolHandlerArguments, the same-origin check is written as:
if (security_level < blink::ProtocolHandlerSecurityLevel::kUntrustedOrigins &&
!origin.IsSameOriginWith(url)) {
return false;
}
Because kExtensionFeatures (3) is not less than kUntrustedOrigins (2), the first half of the condition evaluates to false. The && short-circuits, and the !origin.IsSameOriginWith(url) check is entirely skipped. This allows any extension to register a handler URL for any other extension.
2. UI Spoofing in Permission Prompt
When the browser prompts the user to approve the handler, it instantiates a RegisterProtocolHandlerPermissionRequest. The constructor passes the target handler’s URL (url.DeprecatedGetOriginAsURL()) to the base PermissionRequest class instead of the requesting extension’s origin. Consequently, the user sees a permission prompt asking to grant the victim extension the ability to handle the protocol, hiding the attacker’s involvement completely.
3. Navigation Throttle Bypass
Once registered, the attacker can trigger the protocol handler programmatically using a browser-initiated API like chrome.windows.create({url: "mailto:payload"}). Because the navigation is browser-initiated and lacks an explicit initiator origin, it bypasses the ChildProcessSecurityPolicyImpl::CanRequestURL check (which skips browser-initiated navigations) and the ExtensionNavigationThrottle (which allows navigations with no initiator origin).
Potential Attack Steps
- A malicious extension (Attacker) calls
navigator.registerProtocolHandler("mailto", "chrome-extension://[VICTIM_ID]/options.html?q=%s", "Mail Handler");. - The user sees a spoofed prompt: “Allow [Victim Extension] to open all email links?” and clicks “Allow”.
- The Attacker extension executes
chrome.windows.create({url: "mailto:malicious_payload"});. - The browser intercepts the
mailto:URL and navigates the new window tochrome-extension://[VICTIM_ID]/options.html?q=mailto%3Amalicious_payload. - The Attacker successfully forces a cross-extension navigation with an attacker-controlled query string. If the victim extension has a DOM XSS vulnerability or unsafely processes URL parameters, the attacker achieves code execution in the victim’s privileged context.
Suggested Fix
- Fix the Same-Origin Check: In
WebContentsImpl::AreValidRegisterProtocolHandlerArguments, revise the logic so that the same-origin check applies correctly tokExtensionFeatures. If extensions are meant to registerchrome-extension://URLs, explicitly enforce that the extension ID of the requesting origin matches the extension ID of the handler URL. - Fix the UI Spoofing: In
Browser::RegisterProtocolHandler, pass therequesting_frame’s actual origin down toRegisterProtocolHandlerPermissionRequest, and use that true initiator origin for the UI prompt rather than the target URL’s origin.
Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8
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. Please feel free to reach out to me if you have concerns or feedback.