Overview

Low
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in ExtensionsMenu
DescriptionUI misrepresentation in ExtensionsMenu
ComponentExtensionsMenu
Bug ClassSpoofing
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.
Tracker423956129
Fix commit796218b6b298 (chromium/src) +103/-3
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedHafiizh
Reported2025-06-11 · bounty $500 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-09-22
Patch gap34 days — fix public in source before the advisory

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.cc that turns the active WebContents’s last committed URL into the human-readable string embedded in the site-settings toggle label.
`FormatUrlForDisplayOmitSchemePathAndTrivialSubdomains`
a url_formatter routine 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.

Key insight
The single mistake was formatting the full raw URL for a UI trust label without stripping the attacker-controllable query and fragment, so the fix clears those components (ClearQuery/ClearRef) before formatting to guarantee the label conveys only a stable path/host identifier.

Attack Path

  1. 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%#.
  2. 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 the WebContents.
  3. Open the extensions menu The victim opens the ExtensionsMenu, which calls GetFormattedHostForDisplay and embeds the unsanitized URL string into the site-settings toggle label.
  4. 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.
  5. 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

An attacker gains the ability to spoof the site-settings label text shown in the browser-process 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.cc
  • chrome/browser/extensions/extension_ui_util.cc

Audit Directions

  • URL display without component stripping
    Search other UI trust surfaces that call FormatUrlForDisplay* or embed GetLastCommittedURL() into labels/dialogs without clearing query and fragment, especially where file:// or non-http schemes are reachable.
  • Scheme-dependent formatting assumptions
    Review 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 prompts
    Audit 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.
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
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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
+}
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.