Medium chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in Browser
DescriptionIncomplete cleanup in Browser
ComponentBrowser
Bug ClassLogic Error
Tracker511754574
Fix commit4fccde21b042 (chromium/src) +361/-42
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
RegisterProtocolHandlerExtensionBrowserTest
chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
modified

Files Changed

  • chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
  • chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
From 4fccde21b042fd9b7487e630c08bda96a3407769 Mon Sep 17 00:00:00 2001
From: Javier Fernández García-Boente <[email protected]>
Date: Sat, 15 Aug 2026 09:26:48 -0700
Subject: [PATCH] Reland "Custom handlers: tie extension-registered handlers to their extension"

This is a reland of commit 6aaad9dc9d6d602d9415612ca5199e9fba068b8b

Original change's description:
> Custom handlers: tie extension-registered handlers to their extension
>
> A protocol handler at the kExtensionFeatures security level relaxes the
> HTML-spec restrictions (allowing cross-origin target URLs and the
> ext+/chrome-extension schemes). Extension cleanup -- uninstall/disable
> handling and ProtocolHandlersSanityCheck -- keys off the handler's
> extension_id, so a handler with the elevated level but no extension_id
> can never be removed: its privileges survive the registering extension's
> removal, and a build that predates recording the extension_id at
> registration time could have persisted such an orphan to prefs.
>
> In this CL we associate such handlers with the registering extension:
>
> - In Browser, centralize handler construction in a new
>   CreateProtocolHandlerForFrame() helper. For a privileged extension
>   frame it builds the handler with the owning extension's id.
>
> - In ProtocolHandlersManager::OnExtensionUnloaded, remove every handler
>   returned by GetExtensionProtocolHandlers(extension->id()) instead of
>   only the manifest-declared ones.
>
> Additionally, a new method ProtocolHandler::IsAllowedExtensionHandler()
> adds an extra security check which drops orphaned handlers on reload
> rather than re-registering them with their elevated privileges intact.
>
> Add a browser test, HandlerRemovedOnExtensionUninstall, that registers a
> handler from an extension page through the JS API and verifies it is
> associated with the extension and removed on uninstall.
>
> Add also a regression test covering the orphan rejection and the pref
> round-trip, and update the two scheme-validation tests that previously
> registered untagged kExtensionFeatures handlers to use realistic
> extension-tagged handlers.
>
> Bug: 511754574
> Change-Id: I404c00a7c69a06443c8ece8f75ebe9fbef065a48
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8103530
> Reviewed-by: Ted Choc <[email protected]>
> Reviewed-by: Antonio Sartori <[email protected]>
> Reviewed-by: Solomon Kinard <[email protected]>
> Commit-Queue: Javier Fernandez <[email protected]>
> Cr-Commit-Position: refs/heads/main@{#1678351}

Bug: 511754574
Change-Id: Ia77132d66c1b98e26fe986f9e574cd04a37e61df
Include-Ci-Only-Tests: chromium.mac:mac26-arm64-rel-tests|browser_tests
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8259429
Reviewed-by: Solomon Kinard <[email protected]>
Commit-Queue: Javier Fernandez <[email protected]>
Reviewed-by: Antonio Sartori <[email protected]>
Reviewed-by: Ted Choc <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1680194}
---

diff --git a/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc b/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
index b02bbb4..a802795 100644
--- a/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
+++ b/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
@@ -18,6 +18,8 @@
  protected:
   ChromeProtocolHandlerRegistryTest() = default;
 
+  // Extension handlers shouldn't use this method. See the comment in the
+  // definition of the ExtensionHandlerCanRegisterProtocol below.
   bool ProtocolHandlerCanRegisterProtocol(
       const std::string& protocol,
       const GURL& handler_url,
@@ -28,6 +30,18 @@
     return registry_->IsHandledProtocol(protocol);
   }
 
+  // Like above, but registers an extension handler (kExtensionFeatures plus the
+  // associated extension id). Extension handlers start unconfirmed, so success
+  // is checked via the handler list rather than IsHandledProtocol.
+  bool ExtensionHandlerCanRegisterProtocol(const std::string& protocol,
+                                           const GURL& handler_url,
+                                           const std::string& extension_id) {
+    registry_->OnAcceptRegisterProtocolHandler(
+        ProtocolHandler::CreateExtensionProtocolHandler(protocol, handler_url,
+                                                        extension_id));
+    return !registry_->GetHandlersFor(protocol).empty();
+  }
+
   void SetUp() override {
     profile_ = std::make_unique<TestingProfile>();
     CHECK(profile_->GetPrefs());
@@ -64,8 +78,10 @@
       "news", chrome_extension_handler_url,
       blink::ProtocolHandlerSecurityLevel::kUntrustedOrigins));
 
-  EXPECT_TRUE(ProtocolHandlerCanRegisterProtocol(
-      "news", chrome_extension_handler_url,
-      blink::ProtocolHandlerSecurityLevel::kExtensionFeatures));
+  // A chrome-extension:// target URL is only allowed at the kExtensionFeatures
+  // level, which is reserved for extension handlers, so register one via the
+  // extension factory (which supplies the required extension id).
+  EXPECT_TRUE(ExtensionHandlerCanRegisterProtocol(
+      "news", chrome_extension_handler_url, "extension_id"));
 }
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
diff --git a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
index db29165..07bf17a8 100644
--- a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
+++ b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
@@ -286,8 +286,73 @@
   ASSERT_EQ(0u, registry->GetHandlersFor(url.GetScheme()).size());
 }
 
-using RegisterProtocolHandlerExtensionBrowserTest =
-    extensions::ExtensionBrowserTest;
+class RegisterProtocolHandlerExtensionBrowserTest
+    : public extensions::ExtensionBrowserTest {
+ public:
+  void SetUpOnMainThread() override {
+    extensions::ExtensionBrowserTest::SetUpOnMainThread();
+
+    // Disable OS-level registration, as
+    // ChromeRegisterProtocolHandlerBrowserTest does; see
+    // TestProtocolHandlerRegistryDelegate above. Without it these tests do a
+    // real OS round trip for every default they register, and its asynchronous
+    // reply clears the default handler again on the platforms where
+    // ShouldRemoveHandlersNotInOS() is true.
+    ProtocolHandlerRegistryFactory::GetForBrowserContext(
+        browser()->GetProfile())
+        ->SetDelegateForTesting(
+            std::make_unique<TestProtocolHandlerRegistryDelegate>());
+  }
+};
+
+// A handler registered from a privileged extension page via
+// navigator.registerProtocolHandler must be associated with the registering
+// extension and removed when that extension is uninstalled.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+                       HandlerRemovedOnExtensionUninstall) {
+#if BUILDFLAG(IS_MAC)
+  ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+  permissions::PermissionRequestManager::FromWebContents(
+      browser()->tab_strip_model()->GetActiveWebContents())
+      ->set_auto_response_for_test(
+          permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  const extensions::Extension* extension =
+      LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+  ASSERT_NE(nullptr, extension);
+  const std::string extension_id = extension->id();
+
+  ProtocolHandlerRegistry* registry =
+      ProtocolHandlerRegistryFactory::GetForBrowserContext(
+          browser()->GetProfile());
+
+  // Register a handler from the extension page via the JS API. The waiter must
+  // only cover the registration; see the comment in
+  // JsHandlerDoesNotOverrideNonExtensionDefault.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), GURL("chrome-extension://" + extension_id + "/test.html")));
+  {
+    ProtocolHandlerChangeWaiter waiter(registry);
+    ASSERT_TRUE(content::ExecJs(
+        browser()->tab_strip_model()->GetActiveWebContents(),
+        "navigator.registerProtocolHandler('geo', 'test.html?%s', 'test');"));
+    waiter.Wait();
+  }
+  ASSERT_TRUE(registry->IsHandledProtocol("geo"));
+
+  // The handler must be associated with the registering extension so that it
+  // is visible to extension cleanup.
+  ProtocolHandlerRegistry::ProtocolHandlerList extension_handlers =
+      registry->GetExtensionProtocolHandlers(extension_id);
+  ASSERT_EQ(1u, extension_handlers.size());
+  EXPECT_EQ("geo", extension_handlers[0].protocol());
+
+  // Uninstalling the extension must remove the handler it registered.
+  UninstallExtension(extension_id);
+  EXPECT_FALSE(registry->IsHandledProtocol("geo"));
+  EXPECT_TRUE(registry->GetExtensionProtocolHandlers().empty());
+}
 
 IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest, Basic) {
 #if BUILDFLAG(IS_MAC)
@@ -305,13 +370,14 @@
   std::string handler_url =
       "chrome-extension://" + extension->id() + "/test.html";
 
-  // Register the handler.
+  // Register the handler. The waiter must only cover the registration; see the
+  // comment in JsHandlerDoesNotOverrideNonExtensionDefault.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), GURL(handler_url)));
   {
     ProtocolHandlerRegistry* registry =
         ProtocolHandlerRegistryFactory::GetForBrowserContext(
             browser()->GetProfile());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc b/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
index b02bbb4..a802795 100644
--- a/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
+++ b/chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
@@ -18,6 +18,8 @@
  protected:
   ChromeProtocolHandlerRegistryTest() = default;
 
+  // Extension handlers shouldn't use this method. See the comment in the
+  // definition of the ExtensionHandlerCanRegisterProtocol below.
   bool ProtocolHandlerCanRegisterProtocol(
       const std::string& protocol,
       const GURL& handler_url,
@@ -28,6 +30,18 @@
     return registry_->IsHandledProtocol(protocol);
   }
 
+  // Like above, but registers an extension handler (kExtensionFeatures plus the
+  // associated extension id). Extension handlers start unconfirmed, so success
+  // is checked via the handler list rather than IsHandledProtocol.
+  bool ExtensionHandlerCanRegisterProtocol(const std::string& protocol,
+                                           const GURL& handler_url,
+                                           const std::string& extension_id) {
+    registry_->OnAcceptRegisterProtocolHandler(
+        ProtocolHandler::CreateExtensionProtocolHandler(protocol, handler_url,
+                                                        extension_id));
+    return !registry_->GetHandlersFor(protocol).empty();
+  }
+
   void SetUp() override {
     profile_ = std::make_unique<TestingProfile>();
     CHECK(profile_->GetPrefs());
@@ -64,8 +78,10 @@
       "news", chrome_extension_handler_url,
       blink::ProtocolHandlerSecurityLevel::kUntrustedOrigins));
 
-  EXPECT_TRUE(ProtocolHandlerCanRegisterProtocol(
-      "news", chrome_extension_handler_url,
-      blink::ProtocolHandlerSecurityLevel::kExtensionFeatures));
+  // A chrome-extension:// target URL is only allowed at the kExtensionFeatures
+  // level, which is reserved for extension handlers, so register one via the
+  // extension factory (which supplies the required extension id).
+  EXPECT_TRUE(ExtensionHandlerCanRegisterProtocol(
+      "news", chrome_extension_handler_url, "extension_id"));
 }
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
diff --git a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
index db29165..07bf17a8 100644
--- a/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
+++ b/chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
@@ -286,8 +286,73 @@
   ASSERT_EQ(0u, registry->GetHandlersFor(url.GetScheme()).size());
 }
 
-using RegisterProtocolHandlerExtensionBrowserTest =
-    extensions::ExtensionBrowserTest;
+class RegisterProtocolHandlerExtensionBrowserTest
+    : public extensions::ExtensionBrowserTest {
+ public:
+  void SetUpOnMainThread() override {
+    extensions::ExtensionBrowserTest::SetUpOnMainThread();
+
+    // Disable OS-level registration, as
+    // ChromeRegisterProtocolHandlerBrowserTest does; see
+    // TestProtocolHandlerRegistryDelegate above. Without it these tests do a
+    // real OS round trip for every default they register, and its asynchronous
+    // reply clears the default handler again on the platforms where
+    // ShouldRemoveHandlersNotInOS() is true.
+    ProtocolHandlerRegistryFactory::GetForBrowserContext(
+        browser()->GetProfile())
+        ->SetDelegateForTesting(
+            std::make_unique<TestProtocolHandlerRegistryDelegate>());
+  }
+};
+
+// A handler registered from a privileged extension page via
+// navigator.registerProtocolHandler must be associated with the registering
+// extension and removed when that extension is uninstalled.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+                       HandlerRemovedOnExtensionUninstall) {
+#if BUILDFLAG(IS_MAC)
+  ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+  permissions::PermissionRequestManager::FromWebContents(
+      browser()->tab_strip_model()->GetActiveWebContents())
+      ->set_auto_response_for_test(
+          permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  const extensions::Extension* extension =
+      LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+  ASSERT_NE(nullptr, extension);
+  const std::string extension_id = extension->id();
+
+  ProtocolHandlerRegistry* registry =
+      ProtocolHandlerRegistryFactory::GetForBrowserContext(
+          browser()->GetProfile());
+
+  // Register a handler from the extension page via the JS API. The waiter must
+  // only cover the registration; see the comment in
+  // JsHandlerDoesNotOverrideNonExtensionDefault.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), GURL("chrome-extension://" + extension_id + "/test.html")));
+  {
+    ProtocolHandlerChangeWaiter waiter(registry);
+    ASSERT_TRUE(content::ExecJs(
+        browser()->tab_strip_model()->GetActiveWebContents(),
+        "navigator.registerProtocolHandler('geo', 'test.html?%s', 'test');"));
+    waiter.Wait();
+  }
+  ASSERT_TRUE(registry->IsHandledProtocol("geo"));
+
+  // The handler must be associated with the registering extension so that it
+  // is visible to extension cleanup.
+  ProtocolHandlerRegistry::ProtocolHandlerList extension_handlers =
+      registry->GetExtensionProtocolHandlers(extension_id);
+  ASSERT_EQ(1u, extension_handlers.size());
+  EXPECT_EQ("geo", extension_handlers[0].protocol());
+
+  // Uninstalling the extension must remove the handler it registered.
+  UninstallExtension(extension_id);
+  EXPECT_FALSE(registry->IsHandledProtocol("geo"));
+  EXPECT_TRUE(registry->GetExtensionProtocolHandlers().empty());
+}
 
 IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest, Basic) {
 #if BUILDFLAG(IS_MAC)
@@ -305,13 +370,14 @@
   std::string handler_url =
       "chrome-extension://" + extension->id() + "/test.html";
 
-  // Register the handler.
+  // Register the handler. The waiter must only cover the registration; see the
+  // comment in JsHandlerDoesNotOverrideNonExtensionDefault.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), GURL(handler_url)));
   {
     ProtocolHandlerRegistry* registry =
         ProtocolHandlerRegistryFactory::GetForBrowserContext(
             browser()->GetProfile());
     ProtocolHandlerChangeWaiter waiter(registry);
-    ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), GURL(handler_url)));
     ASSERT_TRUE(content::ExecJs(
         browser()->tab_strip_model()->GetActiveWebContents(),
         "navigator.registerProtocolHandler('geo', 'test.html?%s', 'test');"));
@@ -326,6 +392,108 @@
                                                    ->GetLastCommittedURL());
 }
 
+// A handler registered from an extension page via
+// navigator.registerProtocolHandler uses the elevated kExtension security
+// level, so it must not take the default away from a pre-existing
+// non-extension default handler.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+                       JsHandlerDoesNotOverrideNonExtensionDefault) {
+#if BUILDFLAG(IS_MAC)
+  ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+  permissions::PermissionRequestManager::FromWebContents(
+      browser()->tab_strip_model()->GetActiveWebContents())
+      ->set_auto_response_for_test(
+          permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  ProtocolHandlerRegistry* registry =
+      ProtocolHandlerRegistryFactory::GetForBrowserContext(
+          browser()->GetProfile());
+
+  // A non-extension (e.g. WebAPI/PWA) handler is already the default for 'geo'.
+  ProtocolHandler non_extension_handler =
+      ProtocolHandler::CreateProtocolHandler(
+          "geo", GURL("https://non-extension.example/%s"));
+  registry->OnAcceptRegisterProtocolHandler(non_extension_handler);
+  ASSERT_TRUE(registry->IsDefault(non_extension_handler));
+
+  const extensions::Extension* extension =
+      LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+  ASSERT_NE(nullptr, extension);
+
+  // Register a 'geo' handler from the extension page via the JS API. The waiter
+  // only covers the registration itself: it quits on the first registry change,
+  // so arming it before the navigation would let an unrelated change satisfy it
+  // and return before the handler is stored.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), GURL("chrome-extension://" + extension->id() + "/test.html")));
+  {
+    ProtocolHandlerChangeWaiter waiter(registry);
+    ASSERT_TRUE(content::ExecJs(
+        browser()->tab_strip_model()->GetActiveWebContents(),
+        "navigator.registerProtocolHandler('geo', 'test.html?%s', 'test');"));
+    waiter.Wait();
+  }
+
+  // The extension handler is registered, but the non-extension handler must
+  // remain the default.
+  EXPECT_EQ(1u, registry->GetExtensionProtocolHandlers(extension->id()).size());
+  EXPECT_TRUE(registry->IsDefault(non_extension_handler));
+  EXPECT_FALSE(registry->GetHandlerFor("geo").IsExtensionHandler());
+}
+
+// When the pre-existing default is itself an extension handler, a handler
+// registered from an extension page via navigator.registerProtocolHandler may
+// become the default: the restriction is only against overriding a
+// non-extension handler.
+IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerExtensionBrowserTest,
+                       JsHandlerOverridesExtensionDefault) {
+#if BUILDFLAG(IS_MAC)
+  ASSERT_TRUE(test::RegisterAppWithLaunchServices());
+#endif
+  permissions::PermissionRequestManager::FromWebContents(
+      browser()->tab_strip_model()->GetActiveWebContents())
+      ->set_auto_response_for_test(
+          permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  ProtocolHandlerRegistry* registry =
+      ProtocolHandlerRegistryFactory::GetForBrowserContext(
+          browser()->GetProfile());
+
+  // Another extension's handler is already the default for 'geo'.
+  ProtocolHandler other_extension_handler =
+      ProtocolHandler::CreateExtensionProtocolHandler(
+          "geo", GURL("https://other-extension.example/%s"),
+          "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+  other_extension_handler.Confirm();
+  registry->OnAcceptRegisterProtocolHandler(other_extension_handler);
+  ASSERT_TRUE(registry->IsDefault(other_extension_handler));
+
+  const extensions::Extension* extension =
+      LoadExtension(test_data_dir_.AppendASCII("protocol_handler"));
+  ASSERT_NE(nullptr, extension);
+
+  // Register a 'geo' handler from the extension page via the JS API. The waiter
+  // must only cover the registration; see the comment in
+  // JsHandlerDoesNotOverrideNonExtensionDefault.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), GURL("chrome-extension://" + extension->id() + "/test.html")));
+  {
+    ProtocolHandlerChangeWaiter waiter(registry);
+    ASSERT_TRUE(content::ExecJs(
+        browser()->tab_strip_model()->GetActiveWebContents(),
+        "navigator.registerProtocolHandler('geo', 'test.html?%s', 'test');"));
+    waiter.Wait();
+  }
+
+  // The newly registered extension handler overrides the previous extension
+  // default.
+  EXPECT_FALSE(registry->IsDefault(other_extension_handler));
+  const ProtocolHandler& new_default = registry->GetHandlerFor("geo");
+  ASSERT_TRUE(new_default.extension_id().has_value());
+  EXPECT_EQ(extension->id(), *new_default.extension_id());
+}
+
 class ChromeRegisterProtocolHandlerAndServiceWorkerInterceptor
     : public InProcessBrowserTest {
  public:
diff --git a/chrome/test/data/extensions/api_test/protocol_handler/test_registration.js b/chrome/test/data/extensions/api_test/protocol_handler/test_registration.js
index 2a18c2b..6ae1d29b 100644
--- a/chrome/test/data/extensions/api_test/protocol_handler/test_registration.js
+++ b/chrome/test/data/extensions/api_test/protocol_handler/test_registration.js
@@ -122,8 +122,14 @@
     async function chromeExtensionURL() {
       chrome.test.assertTrue(
           SAME_ORIGIN_CHROME_EXTENSION_URL.startsWith('chrome-extension://'));
+      // Use a safelisted scheme with no predefined default handler. mailto and
+      // webcal must be avoided here: on Chrome OS they ship a predefined
+      // non-extension default handler, and an extension handler (kExtension
+      // security level) is not allowed to override a non-extension default, so
+      // the extension handler would never become the default and the
+      // navigation below would not resolve through it.
       await testRegisterProtocolHandler(
-          'mailto', SAME_ORIGIN_CHROME_EXTENSION_URL, TITLE);
+          'xmpp', SAME_ORIGIN_CHROME_EXTENSION_URL, TITLE);
       chrome.test.succeed();
     },
diff --git a/components/custom_handlers/protocol_handler_registry_unittest.cc b/components/custom_handlers/protocol_handler_registry_unittest.cc
index f186896..552783c 100644
--- a/components/custom_handlers/protocol_handler_registry_unittest.cc
+++ b/components/custom_handlers/protocol_handler_registry_unittest.cc
@@ -342,6 +342,48 @@
   EXPECT_EQ(now, recreated.last_modified());
 }
 
+// A handler at the kExtensionFeatures security level with no extension_id is an
+// orphan: extension cleanup keys off the extension_id, so such a handler can
+// never be removed on uninstall/disable. Builds from before the extension_id
+// was populated at registration time could persist one to prefs, where its
+// elevated level would keep validating a cross-origin URL indefinitely.
+// IsAllowedExtensionHandler() must reject it so it is dropped when reloaded
+// rather than silently re-registered with its elevated privileges intact.
+TEST_F(ProtocolHandlerRegistryTest,
+       ExtensionFeaturesHandlerWithoutExtensionIdIsRejected) {
+  base::Time now = base::Time::Now();
+  const GURL cross_origin_url("https://attacker.example/steal?url=%s");
+
+  ProtocolHandler orphan(
+      "mailto", cross_origin_url, /*app_id=*/std::nullopt,
+      /*extension_id=*/std::nullopt, now,
+      /*is_confirmed=*/true, /*is_allowed_in_incognito=*/false,
+      blink::ProtocolHandlerSecurityLevel::kExtensionFeatures);
+  // The orphan is well-formed per the HTML spec (safelisted scheme, trustworthy
+  // URL); it is the missing extension association -- a concern separate from
+  // IsValid() -- that must disqualify it.
+  EXPECT_TRUE(orphan.IsValid());
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.