CVE-2026-113138
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/location_bar/custom_tab_bar_view.cc |
modified | |
forchrome/browser/ui/web_applications/app_browser_controller_unittest.cc |
modified |
Files Changed
chrome/browser/ui/views/location_bar/custom_tab_bar_view.ccchrome/browser/ui/web_applications/app_browser_controller_unittest.ccchrome/browser/ui/views/location_bar/custom_tab_bar_view.hchrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.ccchrome/browser/ui/web_applications/app_browser_controller.cc
Patch
From e17009278376b12705c8b095d11897482abeacac Mon Sep 17 00:00:00 2001 From: Adriana Ixba <[email protected]> Date: Mon, 21 Sep 2026 19:22:24 -0700 Subject: [PATCH] [PWA] Fix CustomTabBarView origin spoof via opaque origins When an installed Desktop PWA navigates to an opaque-origin URL (such as about:blank#<padding>https://victim.com/login), AppBrowserController::FormatUrlOrigin previously formatted the full raw URL including fragment and query, and CustomTabBarTitleOriginView unconditionally applied head-elision (ELIDE_HEAD). This caused the leading about:blank prefix to be elided, leaving only the attacker-controlled HTTPS origin visible in the trusted origin bar. This CL fixes the issue by: 1. Updating AppBrowserController::FormatUrlOrigin() to return 'about:blank' and 'about:srcdoc' directly without query/fragment, and clearing ref and query components when formatting other opaque origins. 2. Updating CustomTabBarView to only use ELIDE_HEAD for non-opaque HTTP/HTTPS origins where the registrable domain is at the tail, and using ELIDE_TAIL for opaque or non-HTTP/HTTPS URLs. 3. Adding unit and browser test coverage. Bug: 561396577 Change-Id: If3234027f1ee17ede3565a81015881fea9d2b2c6 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8406574 Commit-Queue: Adriana Ixba <[email protected]> Reviewed-by: Daniel Murphy <[email protected]> Reviewed-by: Kaan Alsan <[email protected]> Cr-Commit-Position: refs/heads/main@{#1702321} --- diff --git a/chrome/browser/ui/views/location_bar/custom_tab_bar_view.cc b/chrome/browser/ui/views/location_bar/custom_tab_bar_view.cc index 35b4ef5..00689038 100644 --- a/chrome/browser/ui/views/location_bar/custom_tab_bar_view.cc +++ b/chrome/browser/ui/views/location_bar/custom_tab_bar_view.cc @@ -55,6 +55,7 @@ #include "ui/views/style/typography_provider.h" #include "ui/views/view_class_properties.h" #include "url/gurl.h" +#include "url/origin.h" namespace { @@ -136,10 +137,13 @@ .SetCrossAxisAlignment(views::LayoutAlignment::kStart); } - void Update(const std::u16string title, const std::u16string location) { + void Update(const std::u16string title, + const std::u16string location, + gfx::ElideBehavior location_elide_behavior) { if (title_label_) { title_label_->SetText(title); } + location_label_->SetElideBehavior(location_elide_behavior); location_label_->SetText(location); location_label_->SetVisible(!location.empty()); } @@ -193,6 +197,10 @@ return location_label_ && location_label_->GetVisible(); } + gfx::ElideBehavior GetLocationElideBehaviorForTesting() const { + return location_label_->GetElideBehavior(); + } + private: // Can be nullptr. raw_ptr<views::Label> title_label_ = nullptr; @@ -373,12 +381,22 @@ title = WindowMetadataController::FormatTitleForDisplay( entry->GetTitleForDisplay()); } + const GURL visible_url = contents->GetVisibleURL(); if (ShouldDisplayUrl(contents)) { location = web_app::AppBrowserController::FormatUrlOrigin( - contents->GetVisibleURL(), url_formatter::kFormatUrlOmitDefaults); + visible_url, url_formatter::kFormatUrlOmitDefaults); } - title_origin_view_->Update(title, location); + // Only head-elide hierarchical HTTP/HTTPS origins (where the registrable + // domain is at the end). For opaque origins or non-HTTP/HTTPS schemes, tail + // elision is required so that a crafted suffix cannot spoof an HTTPS origin. + const url::Origin origin = url::Origin::Create(visible_url); + const gfx::ElideBehavior location_elide_behavior = + (!origin.opaque() && origin.GetURL().SchemeIsHTTPOrHTTPS()) + ? gfx::ElideBehavior::ELIDE_HEAD + : gfx::ElideBehavior::ELIDE_TAIL; + + title_origin_view_->Update(title, location, location_elide_behavior); location_icon_view_->Update(/*suppress animations = */ false); // Hide location icon if we're already hiding the origin. @@ -462,6 +480,11 @@ return close_button_->GetVisible(); } +gfx::ElideBehavior CustomTabBarView::GetLocationElideBehaviorForTesting() + const { + return title_origin_view_->GetLocationElideBehaviorForTesting(); // IN-TEST +} + void CustomTabBarView::GoBackToApp() { content::WebContents* web_contents = GetWebContents(); content::NavigationController& controller = web_contents->GetController(); diff --git a/chrome/browser/ui/views/location_bar/custom_tab_bar_view.h b/chrome/browser/ui/views/location_bar/custom_tab_bar_view.h index f0a534a..3019051 100644 --- a/chrome/browser/ui/views/location_bar/custom_tab_bar_view.h +++ b/chrome/browser/ui/views/location_bar/custom_tab_bar_view.h @@ -14,6 +14,7 @@ #include "chrome/browser/ui/web_applications/app_browser_controller.h" #include "ui/base/metadata/metadata_header_macros.h" #include "ui/base/mojom/menu_source_type.mojom-forward.h" +#include "ui/gfx/text_constants.h" #include "ui/menus/simple_menu_model.h" #include "ui/views/accessible_pane_view.h" #include "ui/views/context_menu_controller.h" @@ -106,6 +107,7 @@ void GoBackToAppForTesting(); bool IsShowingOriginForTesting() const; bool IsShowingCloseButtonForTesting() const; + gfx::ElideBehavior GetLocationElideBehaviorForTesting() const; private: // Takes the web contents for the custom tab bar back to the app scope. diff --git a/chrome/browser/ui/web_applications/app_browser_controller.cc b/chrome/browser/ui/web_applications/app_browser_controller.cc index 97761517..0c8be26 100644 --- a/chrome/browser/ui/web_applications/app_browser_controller.cc +++ b/chrome/browser/ui/web_applications/app_browser_controller.cc @@ -260,10 +260,23 @@ std::u16string AppBrowserController::FormatUrlOrigin( const GURL& url, url_formatter::FormatUrlTypes format_types) { + if (url.IsAboutBlank()) { + return url::kAboutBlankURL16; + } + if (url.IsAboutSrcdoc()) { + return url::kAboutSrcdocURL16; + } auto origin = url::Origin::Create(url); - return url_formatter::FormatUrl(origin.opaque() ? url : origin.GetURL(), - format_types, base::UnescapeRule::SPACES, - nullptr, nullptr, nullptr); + GURL url_to_format = origin.GetURL(); + if (origin.opaque()) { + GURL::Replacements replacements; + replacements.ClearRef(); + replacements.ClearQuery(); + url_to_format = url.ReplaceComponents(replacements); + } + return url_formatter::FormatUrl(url_to_format, format_types, + base::UnescapeRule::SPACES, nullptr, nullptr, + nullptr); } const ui::ThemeProvider* AppBrowserController::GetThemeProvider() const { diff --git a/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc b/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc index 202ed937..24e7e485 100644 --- a/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc +++ b/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc @@ -775,6 +775,40 @@ app_browser_view->toolbar()->custom_tab_bar()->location_for_testing() + u"/", base::ASCIIToUTF16(embedded_https_test_server().GetURL("/").spec())); + EXPECT_EQ(app_browser_view->toolbar() + ->custom_tab_bar() + ->GetLocationElideBehaviorForTesting(), + gfx::ElideBehavior::ELIDE_HEAD); +} + +// Verify that navigating to an opaque-origin URL (like about:blank with a +// crafted fragment) strips the fragment and uses tail elision rather than +// head elision, preventing origin spoofing. +IN_PROC_BROWSER_TEST_F(CustomTabBarViewBrowserTest, + AboutBlankWithFragmentDoesNotSpoofOrigin) { + InstallPWA( + embedded_https_test_server().GetURL("app.com", "/ssl/google.html")); + ASSERT_TRUE(app_browser_); + EXPECT_EQ(app_browser_->GetType(), BrowserWindowInterface::Type::TYPE_APP); + + BrowserView* app_view = BrowserView::GetBrowserViewForBrowser(app_browser_); + content::WebContents* web_contents = app_view->GetActiveWebContents(); + CustomTabBarView* bar = app_view->toolbar()->custom_tab_bar(); + + NavigateAndWait(web_contents, + GURL("about:blank#////////////////https://victim.com/login")); + + EXPECT_TRUE(bar->GetVisible()); + EXPECT_EQ(bar->location_for_testing(), u"about:blank"); + EXPECT_EQ(bar->GetLocationElideBehaviorForTesting(), + gfx::ElideBehavior::ELIDE_TAIL); + + // Navigating to a hierarchical HTTPS URL should use head elision. + NavigateAndWait(web_contents, GURL("https://example.com/path")); + EXPECT_TRUE(bar->GetVisible()); + EXPECT_EQ(bar->location_for_testing(), u"https://example.com"); + EXPECT_EQ(bar->GetLocationElideBehaviorForTesting(), + gfx::ElideBehavior::ELIDE_HEAD); } // Check that the CustomTabBarView security chip updates when the visible diff --git a/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc b/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc index 86a722b..54eb1fa 100644 --- a/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc +++ b/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc @@ -29,6 +29,13 @@ // comprehensive tests in // components/url_formatter/url_formatter_unittest.cc. {"https://xn--36c-tfa.com", L"xn--36c-tfa.com"}, + // Opaque origins should not retain fragments or queries that could spoof + // an origin when head-elided. + {"about:blank", L"about:blank"}, + {"about:blank#https://victim.com/login", L"about:blank"}, + {"about:blank?https://victim.com/login", L"about:blank"}, + {"about:srcdoc#https://victim.com/login", L"about:srcdoc"}, + {"data:text/html,hello#https://victim.com", L"data:text/html,hello"}, }; for (auto test_case : kTestCases) { EXPECT_EQ(AppBrowserController::FormatUrlOrigin(GURL(test_case.input)),
Regression Test / PoC
diff --git a/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc b/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc
index 202ed937..24e7e485 100644
--- a/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc
+++ b/chrome/browser/ui/views/location_bar/custom_tab_bar_view_browsertest.cc
@@ -775,6 +775,40 @@
app_browser_view->toolbar()->custom_tab_bar()->location_for_testing() +
u"/",
base::ASCIIToUTF16(embedded_https_test_server().GetURL("/").spec()));
+ EXPECT_EQ(app_browser_view->toolbar()
+ ->custom_tab_bar()
+ ->GetLocationElideBehaviorForTesting(),
+ gfx::ElideBehavior::ELIDE_HEAD);
+}
+
+// Verify that navigating to an opaque-origin URL (like about:blank with a
+// crafted fragment) strips the fragment and uses tail elision rather than
+// head elision, preventing origin spoofing.
+IN_PROC_BROWSER_TEST_F(CustomTabBarViewBrowserTest,
+ AboutBlankWithFragmentDoesNotSpoofOrigin) {
+ InstallPWA(
+ embedded_https_test_server().GetURL("app.com", "/ssl/google.html"));
+ ASSERT_TRUE(app_browser_);
+ EXPECT_EQ(app_browser_->GetType(), BrowserWindowInterface::Type::TYPE_APP);
+
+ BrowserView* app_view = BrowserView::GetBrowserViewForBrowser(app_browser_);
+ content::WebContents* web_contents = app_view->GetActiveWebContents();
+ CustomTabBarView* bar = app_view->toolbar()->custom_tab_bar();
+
+ NavigateAndWait(web_contents,
+ GURL("about:blank#////////////////https://victim.com/login"));
+
+ EXPECT_TRUE(bar->GetVisible());
+ EXPECT_EQ(bar->location_for_testing(), u"about:blank");
+ EXPECT_EQ(bar->GetLocationElideBehaviorForTesting(),
+ gfx::ElideBehavior::ELIDE_TAIL);
+
+ // Navigating to a hierarchical HTTPS URL should use head elision.
+ NavigateAndWait(web_contents, GURL("https://example.com/path"));
+ EXPECT_TRUE(bar->GetVisible());
+ EXPECT_EQ(bar->location_for_testing(), u"https://example.com");
+ EXPECT_EQ(bar->GetLocationElideBehaviorForTesting(),
+ gfx::ElideBehavior::ELIDE_HEAD);
}
// Check that the CustomTabBarView security chip updates when the visible
diff --git a/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc b/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc
index 86a722b..54eb1fa 100644
--- a/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc
+++ b/chrome/browser/ui/web_applications/app_browser_controller_unittest.cc
@@ -29,6 +29,13 @@
// comprehensive tests in
// components/url_formatter/url_formatter_unittest.cc.
{"https://xn--36c-tfa.com", L"xn--36c-tfa.com"},
+ // Opaque origins should not retain fragments or queries that could spoof
+ // an origin when head-elided.
+ {"about:blank", L"about:blank"},
+ {"about:blank#https://victim.com/login", L"about:blank"},
+ {"about:blank?https://victim.com/login", L"about:blank"},
+ {"about:srcdoc#https://victim.com/login", L"about:srcdoc"},
+ {"data:text/html,hello#https://victim.com", L"data:text/html,hello"},
};
for (auto test_case : kTestCases) {
EXPECT_EQ(AppBrowserController::FormatUrlOrigin(GURL(test_case.input)),
Original Bug Report
PWA CustomTabBarView origin spoof via opaque-origin raw-URL fallback + ELIDE_HEAD
PWA CustomTabBarView origin spoof via opaque-origin raw-URL fallback + ELIDE_HEAD
Component: chrome/browser/ui/web_applications/app_browser_controller.cc · chrome/browser/ui/views/location_bar/custom_tab_bar_view.cc
Tested: Chrome M155 trunk, 6a9e8b23 (2026-09-14). Reproduced on macOS + Linux.
Summary
AppBrowserController::FormatUrlOrigin formats the entire raw URL (including fragment) when the origin is opaque. CustomTabBarView head-elides this string unconditionally with no scheme gate. An installed PWA navigating to about:blank#<padding>https://victim.com/login causes the trusted origin bar to display …https://victim.com/login — an attacker-chosen HTTPS origin — while the page is actually about:blank. The security chip shows neutral (not DANGEROUS).
Desktop C++ twin of the Android Document-PiP spoof fixed in 544b1a332661a (bug 553145497). That fix was Android-scoped; this desktop path is still live.
Steps to reproduce
- Unzip
poc/and serve it:python3 -m http.server 8076 - In Chrome, visit
http://localhost:8076/and install the PWA (address-bar icon or Menu → Install). - In the PWA window, click “Trigger spoof”.
- Observe the CustomTabBarView (blue bar below title bar): the head-elided line ends with
https://accounts.google.com/signin; the tooltip line showsabout:blank#/ServiceLogin?continue=…— a Google-like URL that is actually attacker-crafted. Seeevidence_linux.png.
Root cause
Three facts combine (all present at 6a9e8b23):
-
app_browser_controller.cc:264—FormatUrlOriginpasses the raw URL toFormatUrlwhenorigin.opaque()is true.kFormatUrlOmitDefaultsdoes not includekFormatUrlOmitRef, so the attacker’s fragment payload survives formatting. -
custom_tab_bar_view.cc:111—SetElideBehavior(gfx::ELIDE_HEAD)unconditionally. Head-elide keeps the end of the string, which is the attacker’s fake origin. (Desktop Document-PiP header switches to ELIDE_TAIL for non-http schemes — this view doesn’t.) -
custom_tab_bar_view.cc:61,369—ShouldDisplayUrlhas no scheme gate (only excludes interstitials), and the label is built fromGetVisibleURL().about:blankpasses through.
Impact
The CustomTabBarView is the only origin indicator in an installed PWA window — there is no omnibox. A spoofed HTTPS origin in this bar, combined with a neutral security chip, gives the user no signal that they are on about:blank rather than the displayed site. The attacker controls the padding and target origin, so any HTTPS site (banks, Google, identity providers) can be impersonated. The navigation can be auto-triggered by JS after install, requiring no further user interaction. The PoC demonstrates padding that blends with Google OAuth URLs, making even the un-elided tooltip line appear legitimate.
Suggested fix
Gate opaque/non-http(s) URLs the way the Document-PiP header does: display a non-spoofable placeholder for opaque origins, or switch to ELIDE_TAIL and omit the fragment for non-http(s) schemes.
Artifacts
poc/— installable PWA (manifest.json, sw.js, index.html, icon.png)evidence_linux.png— PWA window on Linux (Chrome 151, Ubuntu 26.04) showing spoofed CustomTabBarView. Also reproduced on macOS.