Chrome · Browser
CVE-2026-87436
Logic Error in Browser
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
RegisterProtocolHandlerExtensionBrowserTestchrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc |
modified |
Files Changed
chrome/browser/custom_handlers/chrome_protocol_handler_registry_unittest.ccchrome/browser/custom_handlers/protocol_handler_registry_browsertest.cc
Patch
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/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.
References
On This Page