CVE-2026-95307
Overview
Background
- `ExtensionsMenu`
- the Chrome toolbar menu that lists installed extensions and, per site, shows a site-settings toggle labeled with the current tab’s URL.
- `GetFormattedHostForDisplay`
- a helper in
extension_ui_util.ccthat turns the activeWebContents’s last committed URL into the human-readable string embedded in the site-settings toggle label. - `FormatUrlForDisplayOmitSchemePathAndTrivialSubdomains`
- a
url_formatterroutine that produces a display string with the scheme, path, and trivial subdomains (e.g.www) omitted, but which does not strip the query or fragment. - `GURL::Replacements` / `ClearQuery` / `ClearRef`
- GURL helpers that produce a copy of a URL with its query string and fragment components removed.
Root Cause Analysis
GetFormattedHostForDisplay fed the raw last-committed URL directly into FormatUrlForDisplayOmitSchemePathAndTrivialSubdomains, which for http/https URLs collapses to just the host but for file:// URLs retains the full path along with any query (?) and fragment (#) components verbatim. The security invariant that the site-settings label should reflect only a trustworthy origin/host identifier was violated, because attacker-controlled query and fragment text (e.g. ?20%20%#) flowed unchanged into the “Allow extensions on …” label, allowing the displayed string to be spoofed.
The fix builds a sanitized fixed_url by applying GURL::Replacements with ClearQuery() and ClearRef() before formatting, so the query and fragment are dropped and can no longer influence the label. Because http/https URLs were already reduced to their host, this change is a no-op for them and only affects the file:// case where the extra components were previously leaking through.
ClearQuery/ClearRef) before formatting to guarantee the label conveys only a stable path/host identifier.Attack Path
- Craft a spoofing URL
An attacker constructs a
file://URL whose query and/or fragment contain deceptive characters, e.g.file:///path/page.html?20%20%#. - Get the victim to load it
The victim navigates the active tab to the crafted
file://page so it becomes the last committed URL of theWebContents. - Open the extensions menu
The victim opens the
ExtensionsMenu, which callsGetFormattedHostForDisplayand embeds the unsanitized URL string into the site-settings toggle label. - Label is spoofed
The label renders the verbatim query/fragment (e.g.
Allow extensions on file:///path/page.html?20%20%#), misrepresenting the site identity the user is granting extensions on. - User is misled into a permission decision (inferred) The spoofed label could cause the user to misjudge which site they are enabling extensions for, though the patch itself only demonstrates the display defect and not a concrete downstream compromise.
Impact Assessment
ExtensionsMenu UI, misrepresenting the origin on which extension access is being granted, with the precondition that the victim loads an attacker-crafted file:// URL and opens the extensions menu. This is a UI-misrepresentation (spoofing) issue rather than a memory-safety bug, and the advisory reports it as a logic error in ExtensionsMenu with no exploited-in-the-wild claim. Any escalation into an actual erroneous permission grant or broader consequence is (inferred) and not established by the patch, which only fixes the label content and adds regression tests.Files Changed
chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.ccchrome/browser/extensions/extension_ui_util.cc
Audit Directions
- URL display without component strippingSearch other UI trust surfaces that call
FormatUrlForDisplay*or embedGetLastCommittedURL()into labels/dialogs without clearing query and fragment, especially wherefile://or non-http schemes are reachable. - Scheme-dependent formatting assumptionsReview code that assumes a formatter reduces a URL to its host; verify the assumption holds for
file://,blob:,filesystem:, and other schemes where paths/queries/fragments survive. - Origin-confusion in permission promptsAudit permission and site-settings UI (extensions, notifications, downloads) where the displayed identifier is built from a raw URL, checking that only a canonical origin/host is shown.
Patch
From 796218b6b2984139be54d88992f8f40baedcfd54 Mon Sep 17 00:00:00 2001 From: Hafiizh <[email protected]> Date: Wed, 19 Aug 2026 07:53:22 -0700 Subject: [PATCH] Extensions menu: strip fragment, query from site-settings label The extensions toolbar menu shows a site-settings toggle label built from the current tab URL. When the URL contains a fragment (#), query string (?), the label includes them verbatim, e.g.: 'Allow extensions on file:///path/page.html?20%20%#' This can be abused via a crafted file:// URL to spoof the label shown to the user in the extensions menu. Fix by sanitizing the URL before building the label: - file:// URLs: strip fragment,strip query via FormatUrlForSecurityDisplay() - http/https URLs: unchanged, GetFormattedHostForDisplay() already returns only the host Also adds browser tests to guard against regressions. [email protected] Bug: 423956129 Change-Id: I186998d0794a4596eb32364a314e19baf20fd476 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7707159 Reviewed-by: Solomon Kinard <[email protected]> Commit-Queue: Foromo Daniel Soromou <[email protected]> Reviewed-by: Foromo Daniel Soromou <[email protected]> Auto-Submit: Bug Bounty Channel <[email protected]> Cr-Commit-Position: refs/heads/main@{#1682310} --- diff --git a/chrome/browser/extensions/extension_ui_util.cc b/chrome/browser/extensions/extension_ui_util.cc index c0eea1c..f30ce98 100644 --- a/chrome/browser/extensions/extension_ui_util.cc +++ b/chrome/browser/extensions/extension_ui_util.cc @@ -16,6 +16,7 @@ #include "components/policy/core/common/policy_pref_names.h" #include "components/prefs/pref_service.h" #include "components/url_formatter/elide_url.h" +#include "components/url_formatter/url_fixer.h" #include "components/url_formatter/url_formatter.h" #include "content/public/browser/web_contents.h" #include "extensions/browser/cws_info_service.h" @@ -131,10 +132,20 @@ std::u16string GetFormattedHostForDisplay(content::WebContents& web_contents) { auto url = web_contents.GetLastCommittedURL(); - // Hide the scheme when necessary (e.g hide "https://" but don't - // "chrome://"). + + GURL::Replacements replacements; + replacements.ClearQuery(); + replacements.ClearRef(); + + GURL fixed_url = url.ReplaceComponents(replacements); + + // Format the URL for display by omitting the scheme (http/https), stripping + // fragment(#), query string, trivial subdomains (like 'www') from the + // resulting string to prevent visual clutter or origin confusion. Explicitly + // convert the resulting string_view back to std::u16string for the return + // value. return url_formatter::FormatUrlForDisplayOmitSchemePathAndTrivialSubdomains( - url); + fixed_url); } bool ShouldShowReviewPrompt(const Extension& extension, Profile& profile) { diff --git a/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc b/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc index 22a4cf0..38782fc 100644 --- a/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc +++ b/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc @@ -5,9 +5,14 @@ #include "chrome/browser/ui/extensions/extensions_menu_view_model.h" #include <string> +#include <string_view> +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" #include "base/memory/scoped_refptr.h" +#include "base/test/allow_check_is_test_for_testing.h" #include "base/test/metrics/user_action_tester.h" +#include "base/threading/thread_restrictions.h" #include "chrome/browser/extensions/extension_action_runner.h" #include "chrome/browser/extensions/extension_browsertest.h" #include "chrome/browser/extensions/extension_tab_util.h" @@ -32,6 +37,7 @@ #include "extensions/common/manifest_constants.h" #include "extensions/test/permissions_manager_waiter.h" #include "extensions/test/test_extension_dir.h" +#include "net/base/filename_util.h" #include "net/dns/mock_host_resolver.h" #include "ui/base/l10n/l10n_util.h" #include "ui/gfx/geometry/size.h" @@ -124,6 +130,12 @@ // Navigates the active web contents to a URL on `host_name`. void NavigateTo(std::string_view host_name); + // Writes a test page into a temporary directory owned by this fixture and + // returns its file:// URL with `suffix` appended (e.g. "#section" or + // "?foo=bar"). The temporary directory outlives the call, so the file + // remains readable for the duration of the test. + GURL CreateTempFileUrl(std::string_view suffix); + ExtensionsMenuViewModel* menu_model() { return menu_model_.get(); } SitePermissionsHelper* permissions_helper() { return permissions_helper_.get(); @@ -135,6 +147,7 @@ void TearDownOnMainThread() override; private: + base::ScopedTempDir temp_dir_; std::unique_ptr<TestExtensionsMenuDelegate> menu_delegate_; std::unique_ptr<ExtensionsMenuViewModel> menu_model_; std::unique_ptr<SitePermissionsHelper> permissions_helper_; @@ -204,6 +217,24 @@ ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url)); } +GURL ExtensionsMenuViewModelBrowserTest::CreateTempFileUrl( + std::string_view suffix) { + base::ScopedAllowBlockingForTesting allow_blocking; + + if (!temp_dir_.IsValid()) { + CHECK(temp_dir_.CreateUniqueTempDir()); + } + + constexpr char kTestHtml[] = "<html>test</html>"; + + const base::FilePath file_path = + temp_dir_.GetPath().Append(FILE_PATH_LITERAL("page.html")); + + CHECK(base::WriteFile(file_path, kTestHtml)); + + return GURL(net::FilePathToFileURL(file_path).spec() + std::string(suffix)); +} + void ExtensionsMenuViewModelBrowserTest::SetUpOnMainThread() { ExtensionBrowserTest::SetUpOnMainThread(); host_resolver()->AddRule("*", "127.0.0.1"); @@ -1479,3 +1510,61 @@ ASSERT_EQ(1u, requests.size()); EXPECT_EQ(extension_C->id(), requests[0]); } + +// Tests that GetSiteSettingsState strips '#' fragment from file:// URLs. +IN_PROC_BROWSER_TEST_F(ExtensionsMenuViewModelBrowserTest, + GetSiteSettingsState_FileUrl_StripFragment) { +#if BUILDFLAG(IS_ANDROID) + GTEST_SKIP() << "file:// navigation unsupported on Android browsertests"; +#else + AddExtensionWithHostPermission("Extension", "<all_urls>"); + + const GURL url = CreateTempFileUrl("#section"); + ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url)); + + ExtensionsMenuViewModel::SiteSettingsState state = + menu_model()->GetSiteSettingsState(); + // Label should contain full file path including .html, without '#section'. + EXPECT_TRUE(state.label.find(u"page.html") != std::u16string::npos); + EXPECT_EQ(state.label.find(u'#'), std::u16string::npos); +#endif +} + +// Tests that GetSiteSettingsState strips '?' query from file:// URLs. +IN_PROC_BROWSER_TEST_F(ExtensionsMenuViewModelBrowserTest, + GetSiteSettingsState_FileUrl_StripQuery) { +#if BUILDFLAG(IS_ANDROID) + GTEST_SKIP() << "file:// navigation unsupported on Android browsertests"; +#else + AddExtensionWithHostPermission("Extension", "<all_urls>"); + + const GURL url = CreateTempFileUrl("?foo=bar"); + ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url)); + + ExtensionsMenuViewModel::SiteSettingsState state = + menu_model()->GetSiteSettingsState(); + // Label should contain full file path including .html, without '?foo=bar'. + EXPECT_TRUE(state.label.find(u"page.html") != std::u16string::npos); + EXPECT_EQ(state.label.find(u'?'), std::u16string::npos); +#endif +} + +// Tests that GetSiteSettingsState does not modify a clean file:// URL. +IN_PROC_BROWSER_TEST_F(ExtensionsMenuViewModelBrowserTest, + GetSiteSettingsState_FileUrl_Clean) { +#if BUILDFLAG(IS_ANDROID) + GTEST_SKIP() << "file:// navigation unsupported on Android browsertests"; +#else + AddExtensionWithHostPermission("Extension", "<all_urls>"); + + const GURL url = CreateTempFileUrl(/*suffix=*/""); + ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url)); + + ExtensionsMenuViewModel::SiteSettingsState state = + menu_model()->GetSiteSettingsState(); + // Label should remain unchanged — no #, no ? + EXPECT_TRUE(state.label.find(u"page.html") != std::u16string::npos); + EXPECT_EQ(state.label.find(u'?'), std::u16string::npos); + EXPECT_EQ(state.label.find(u'#'), std::u16string::npos); +#endif +}
Regression Test / PoC
diff --git a/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc b/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc
index 22a4cf0..38782fc 100644
--- a/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc
+++ b/chrome/browser/ui/extensions/extensions_menu_view_model_browsertest.cc
@@ -5,9 +5,14 @@
#include "chrome/browser/ui/extensions/extensions_menu_view_model.h"
#include <string>
+#include <string_view>
+#include "base/files/file_util.h"
+#include "base/files/scoped_temp_dir.h"
#include "base/memory/scoped_refptr.h"
+#include "base/test/allow_check_is_test_for_testing.h"
#include "base/test/metrics/user_action_tester.h"
+#include "base/threading/thread_restrictions.h"
#include "chrome/browser/extensions/extension_action_runner.h"
#include "chrome/browser/extensions/extension_browsertest.h"
#include "chrome/browser/extensions/extension_tab_util.h"
@@ -32,6 +37,7 @@
#include "extensions/common/manifest_constants.h"
#include "extensions/test/permissions_manager_waiter.h"
#include "extensions/test/test_extension_dir.h"
+#include "net/base/filename_util.h"
#include "net/dns/mock_host_resolver.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/geometry/size.h"
@@ -124,6 +130,12 @@
// Navigates the active web contents to a URL on `host_name`.
void NavigateTo(std::string_view host_name);
+ // Writes a test page into a temporary directory owned by this fixture and
+ // returns its file:// URL with `suffix` appended (e.g. "#section" or
+ // "?foo=bar"). The temporary directory outlives the call, so the file
+ // remains readable for the duration of the test.
+ GURL CreateTempFileUrl(std::string_view suffix);
+
ExtensionsMenuViewModel* menu_model() { return menu_model_.get(); }
SitePermissionsHelper* permissions_helper() {
return permissions_helper_.get();
@@ -135,6 +147,7 @@
void TearDownOnMainThread() override;
private:
+ base::ScopedTempDir temp_dir_;
std::unique_ptr<TestExtensionsMenuDelegate> menu_delegate_;
std::unique_ptr<ExtensionsMenuViewModel> menu_model_;
std::unique_ptr<SitePermissionsHelper> permissions_helper_;
@@ -204,6 +217,24 @@
ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
}
+GURL ExtensionsMenuViewModelBrowserTest::CreateTempFileUrl(
+ std::string_view suffix) {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+
+ if (!temp_dir_.IsValid()) {
+ CHECK(temp_dir_.CreateUniqueTempDir());
+ }
+
+ constexpr char kTestHtml[] = "<html>test</html>";
+
+ const base::FilePath file_path =
+ temp_dir_.GetPath().Append(FILE_PATH_LITERAL("page.html"));
+
+ CHECK(base::WriteFile(file_path, kTestHtml));
+
+ return GURL(net::FilePathToFileURL(file_path).spec() + std::string(suffix));
+}
+
void ExtensionsMenuViewModelBrowserTest::SetUpOnMainThread() {
ExtensionBrowserTest::SetUpOnMainThread();
host_resolver()->AddRule("*", "127.0.0.1");
@@ -1479,3 +1510,61 @@
ASSERT_EQ(1u, requests.size());
EXPECT_EQ(extension_C->id(), requests[0]);
}
+
+// Tests that GetSiteSettingsState strips '#' fragment from file:// URLs.
+IN_PROC_BROWSER_TEST_F(ExtensionsMenuViewModelBrowserTest,
+ GetSiteSettingsState_FileUrl_StripFragment) {
+#if BUILDFLAG(IS_ANDROID)
+ GTEST_SKIP() << "file:// navigation unsupported on Android browsertests";
+#else
+ AddExtensionWithHostPermission("Extension", "<all_urls>");
+
+ const GURL url = CreateTempFileUrl("#section");
+ ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+ ExtensionsMenuViewModel::SiteSettingsState state =
+ menu_model()->GetSiteSettingsState();
+ // Label should contain full file path including .html, without '#section'.
+ EXPECT_TRUE(state.label.find(u"page.html") != std::u16string::npos);
+ EXPECT_EQ(state.label.find(u'#'), std::u16string::npos);
+#endif
+}
+
+// Tests that GetSiteSettingsState strips '?' query from file:// URLs.
+IN_PROC_BROWSER_TEST_F(ExtensionsMenuViewModelBrowserTest,
+ GetSiteSettingsState_FileUrl_StripQuery) {
+#if BUILDFLAG(IS_ANDROID)
+ GTEST_SKIP() << "file:// navigation unsupported on Android browsertests";
+#else
+ AddExtensionWithHostPermission("Extension", "<all_urls>");
+
+ const GURL url = CreateTempFileUrl("?foo=bar");
+ ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+ ExtensionsMenuViewModel::SiteSettingsState state =
+ menu_model()->GetSiteSettingsState();
+ // Label should contain full file path including .html, without '?foo=bar'.
+ EXPECT_TRUE(state.label.find(u"page.html") != std::u16string::npos);
+ EXPECT_EQ(state.label.find(u'?'), std::u16string::npos);
+#endif
+}
+
+// Tests that GetSiteSettingsState does not modify a clean file:// URL.
+IN_PROC_BROWSER_TEST_F(ExtensionsMenuViewModelBrowserTest,
+ GetSiteSettingsState_FileUrl_Clean) {
+#if BUILDFLAG(IS_ANDROID)
+ GTEST_SKIP() << "file:// navigation unsupported on Android browsertests";
+#else
+ AddExtensionWithHostPermission("Extension", "<all_urls>");
+
+ const GURL url = CreateTempFileUrl(/*suffix=*/"");
+ ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+ ExtensionsMenuViewModel::SiteSettingsState state =
+ menu_model()->GetSiteSettingsState();
+ // Label should remain unchanged — no #, no ?
+ EXPECT_TRUE(state.label.find(u"page.html") != std::u16string::npos);
+ EXPECT_EQ(state.label.find(u'?'), std::u16string::npos);
+ EXPECT_EQ(state.label.find(u'#'), std::u16string::npos);
+#endif
+}