CVE-2025-12446
Overview
Background
- Tab Search WebUI
- the
chrome://tab-search.top-chromepage (tab_search_page.ts,tab_search_item.css). It lists open and recently closed tabs, showing each tab’s title as#primaryTextand a host string as#secondaryText. The commit’s[SxS]tag ties it to the Split View (“side-by-side”) tab picker named in the advisory. - URL display guidelines
- Chromium’s rules for showing origins in security-relevant UI. When space is short, a hostname must be elided from the front so the registrable domain at the end stays visible, and non-network schemes such as
blob:andfile:get a descriptive label instead of a host. - `direction: rtl` eliding
- a CSS technique that puts the overflow ellipsis at the start of a single-line,
nowraptext box, so the rightmost (most significant) labels of a hostname are what stays visible. - `loadTimeData` strings
- localized strings passed from C++ (
TabSearchUI) into the WebUI. Here these areblobUrlSourceandfileUrlSource, which reuse the hover-card stringsIDS_HOVER_CARD_BLOB_URL_SOURCEandIDS_HOVER_CARD_FILE_URL_SOURCE.
Root Cause Analysis
Before the patch, TabSearchPage.tabData_ built each row’s display host as new URL(normalizeURL(tab.url.url)).hostname, with no handling for the URL’s scheme. The #secondaryText element was ellipsized at the end like normal left-to-right text. This broke two URL-display rules. First, a long attacker-chosen hostname (for example, one beginning with a trusted brand followed by many subdomain labels) was cut off at the end, so the user saw the trusted-looking prefix while the real registrable domain was hidden. Second, blob: and file: tabs were shown by a raw hostname value, not the “temporary data” / “local or shared file” labels already used by hover cards and the mini toolbar.
The fix adds getDisplayHostnameForUrl_, which returns the localized blobUrlSource or fileUrlSource string for those schemes and url.hostname otherwise. It also adds a #secondaryText { direction: rtl; } rule so long hosts are elided from the front, and passes the two strings from tab_search_ui.cc.
blob: and file: URLs.Attack Path
- Host content on a deceptive hostname
The attacker gets the victim to open a tab on a very long hostname whose leading labels imitate a trusted site, or a
blob:URL created by attacker content. - User opens the tab picker
The victim opens Tab Search or the Split View tab picker that uses it, and
tabData_fills in that tab’shostnamefor display. - Misleading origin text is shown
With end-elision, only the trusted-looking leading labels fit in
#secondaryText, and ablob:tab shows a rawhostnamevalue instead of “temporary data”. - User acts on the spoofed identity The victim believes the tab belongs to a trusted site and selects it (for example, to place it in a split view) and then trusts its content (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/resources/tab_search/tab_search_page.ts |
modified |
Files Changed
chrome/browser/resources/tab_search/tab_search_item.csschrome/browser/resources/tab_search/tab_search_page.tschrome/browser/ui/webui/tab_search/tab_search_ui.ccchrome/test/data/webui/tab_search/tab_search_page_test.ts
Audit Directions
- Hostname shown in other WebUI listsSearch WebUI TypeScript for
new URL(...).hostnameor.hostbound into elided text (history, tab groups, side panels, tab strip, read-anything). Check that these use front-elision or the C++url_formatterhelpers and handleblob:,file:,filesystem:anddata:schemes. - CSS ellipsis on origin textLook for
text-overflow: ellipsiswith left-to-right direction on elements that show hosts or origins, since end-elision of a hostname goes against the URL display guidelines. - Inconsistent scheme labeling across surfacesCompare how hover cards, the mini toolbar, Split View and Tab Search label special schemes. A surface that falls back to the raw URL where others show a fixed label is a likely spoofing spot.
Patch
From 5afd445e6deae19de5bf3629b451cea29a7b860e Mon Sep 17 00:00:00 2001 From: Alison Gale <[email protected]> Date: Wed, 17 Sep 2025 12:56:50 -0700 Subject: [PATCH] [SxS] Fix URL eliding for tab search Show "temporary data" for blob: URLs and "local or shared file" for file: URLs to match what we do for hovercards and mini toolbar. Elide URLs from the front to avoid spoofing with long subdomains. Before: https://screenshot.googleplex.com/BFA7KaDQtYpR5ZH After: https://screenshot.googleplex.com/3e9uyAMCfZxxSHL Guidelines: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/url_display_guidelines/url_display_guidelines.md#eliding-urls Bug: 444932667,444915898 Change-Id: Id3a540eaa4392a85f9db2d0b5424d009a1bd271c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6956837 Reviewed-by: Chris Thompson <[email protected]> Reviewed-by: Yuheng Huang <[email protected]> Commit-Queue: Alison Gale <[email protected]> Cr-Commit-Position: refs/heads/main@{#1516831} --- diff --git a/chrome/browser/resources/tab_search/tab_search_item.css b/chrome/browser/resources/tab_search/tab_search_item.css index 8a06e14e..5b3a2ae1 100644 --- a/chrome/browser/resources/tab_search/tab_search_item.css +++ b/chrome/browser/resources/tab_search/tab_search_item.css @@ -79,6 +79,11 @@ white-space: nowrap; } +#secondaryText { + /* Elide hostname from the front to prevent URL spoofing. */ + direction: rtl; +} + #primaryText { color: var(--cr-primary-text-color); font-size: var(--mwb-primary-text-font-size); diff --git a/chrome/browser/resources/tab_search/tab_search_page.ts b/chrome/browser/resources/tab_search/tab_search_page.ts index c58fdc3..3dbd50776 100644 --- a/chrome/browser/resources/tab_search/tab_search_page.ts +++ b/chrome/browser/resources/tab_search/tab_search_page.ts @@ -661,11 +661,22 @@ return ariaLabel(tabData); } + private getDisplayHostnameForUrl_(url: URL): string { + if (url.protocol === 'blob:') { + return loadTimeData.getString('blobUrlSource'); + } else if (url.protocol === 'file:') { + return loadTimeData.getString('fileUrlSource'); + } else { + return url.hostname; + } + } + private tabData_( tab: Tab|RecentlyClosedTab, inActiveWindow: boolean, type: TabItemType, tabGroupsMap: Map<string, TabGroup>): TabData { - const tabData = - new TabData(tab, type, new URL(normalizeURL(tab.url.url)).hostname); + const tabData = new TabData( + tab, type, + this.getDisplayHostnameForUrl_(new URL(normalizeURL(tab.url.url)))); if (tab.groupId) { tabData.tabGroup = tabGroupsMap.get(tokenToString(tab.groupId)); diff --git a/chrome/browser/ui/webui/tab_search/tab_search_ui.cc b/chrome/browser/ui/webui/tab_search/tab_search_ui.cc index c3c82b31..f30546bc 100644 --- a/chrome/browser/ui/webui/tab_search/tab_search_ui.cc +++ b/chrome/browser/ui/webui/tab_search/tab_search_ui.cc @@ -79,10 +79,12 @@ IDS_TAB_SEARCH_A11Y_RECENTLY_CLOSED_TAB_GROUP}, {"audioMuting", IDS_TAB_AX_LABEL_AUDIO_MUTING_FORMAT}, {"audioPlaying", IDS_TAB_AX_LABEL_AUDIO_PLAYING_FORMAT}, + {"blobUrlSource", IDS_HOVER_CARD_BLOB_URL_SOURCE}, {"clearSearch", IDS_CLEAR_SEARCH}, {"closeTab", IDS_TAB_SEARCH_CLOSE_TAB}, {"collapseRecentlyClosed", IDS_TAB_SEARCH_COLLAPSE_RECENTLY_CLOSED}, {"expandRecentlyClosed", IDS_TAB_SEARCH_EXPAND_RECENTLY_CLOSED}, + {"fileUrlSource", IDS_HOVER_CARD_FILE_URL_SOURCE}, {"mediaRecording", IDS_TAB_AX_LABEL_MEDIA_RECORDING_FORMAT}, {"audioRecording", IDS_TAB_AX_LABEL_AUDIO_RECORDING_FORMAT}, {"videoRecording", IDS_TAB_AX_LABEL_VIDEO_RECORDING_FORMAT}, diff --git a/chrome/test/data/webui/tab_search/tab_search_page_test.ts b/chrome/test/data/webui/tab_search/tab_search_page_test.ts index b91f9ca..5183ed0 100644 --- a/chrome/test/data/webui/tab_search/tab_search_page_test.ts +++ b/chrome/test/data/webui/tab_search/tab_search_page_test.ts @@ -4,6 +4,7 @@ import 'chrome://tab-search.top-chrome/tab_search.js'; +import {loadTimeData} from 'chrome://resources/js/load_time_data.js'; import {MetricsReporterImpl} from 'chrome://resources/js/metrics_reporter/metrics_reporter.js'; import type {ProfileData, RecentlyClosedTab, Tab, TabSearchItemElement, TabSearchPageElement} from 'chrome://tab-search.top-chrome/tab_search.js'; import {SEARCH_QUERY_MAX_LENGTH, TabGroupColor, TabSearchApiProxyImpl} from 'chrome://tab-search.top-chrome/tab_search.js'; @@ -844,4 +845,42 @@ const [tabInfo] = await testProxy.whenCalled('switchToTab'); assertEquals(1, tabInfo.tabId); }); + + test('Handles file URLs', async () => { + await setupTest(createProfileData({ + windows: [{ + active: true, + isHostWindow: true, + height: SAMPLE_WINDOW_HEIGHT, + tabs: [createTab({ + title: 'My file', + url: {url: 'file:///home'}, + lastActiveTimeTicks: {internalValue: BigInt(4)}, + })], + }], + })); + const tabSearchItem = + tabSearchPage.$.tabsList.querySelector('tab-search-item')!; + assertEquals( + loadTimeData.getString('fileUrlSource'), tabSearchItem.data.hostname); + }); + + test('Handles blob URLs', async () => { + await setupTest(createProfileData({ + windows: [{ + active: true, + isHostWindow: true, + height: SAMPLE_WINDOW_HEIGHT, + tabs: [createTab({ + title: 'My blob', + url: {url: 'blob:null/foo'}, + lastActiveTimeTicks: {internalValue: BigInt(4)}, + })], + }], + })); + const tabSearchItem = + tabSearchPage.$.tabsList.querySelector('tab-search-item')!; + assertEquals( + loadTimeData.getString('blobUrlSource'), tabSearchItem.data.hostname); + }); });
Regression Test / PoC
diff --git a/chrome/test/data/webui/tab_search/tab_search_page_test.ts b/chrome/test/data/webui/tab_search/tab_search_page_test.ts
index b91f9ca..5183ed0 100644
--- a/chrome/test/data/webui/tab_search/tab_search_page_test.ts
+++ b/chrome/test/data/webui/tab_search/tab_search_page_test.ts
@@ -4,6 +4,7 @@
import 'chrome://tab-search.top-chrome/tab_search.js';
+import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {MetricsReporterImpl} from 'chrome://resources/js/metrics_reporter/metrics_reporter.js';
import type {ProfileData, RecentlyClosedTab, Tab, TabSearchItemElement, TabSearchPageElement} from 'chrome://tab-search.top-chrome/tab_search.js';
import {SEARCH_QUERY_MAX_LENGTH, TabGroupColor, TabSearchApiProxyImpl} from 'chrome://tab-search.top-chrome/tab_search.js';
@@ -844,4 +845,42 @@
const [tabInfo] = await testProxy.whenCalled('switchToTab');
assertEquals(1, tabInfo.tabId);
});
+
+ test('Handles file URLs', async () => {
+ await setupTest(createProfileData({
+ windows: [{
+ active: true,
+ isHostWindow: true,
+ height: SAMPLE_WINDOW_HEIGHT,
+ tabs: [createTab({
+ title: 'My file',
+ url: {url: 'file:///home'},
+ lastActiveTimeTicks: {internalValue: BigInt(4)},
+ })],
+ }],
+ }));
+ const tabSearchItem =
+ tabSearchPage.$.tabsList.querySelector('tab-search-item')!;
+ assertEquals(
+ loadTimeData.getString('fileUrlSource'), tabSearchItem.data.hostname);
+ });
+
+ test('Handles blob URLs', async () => {
+ await setupTest(createProfileData({
+ windows: [{
+ active: true,
+ isHostWindow: true,
+ height: SAMPLE_WINDOW_HEIGHT,
+ tabs: [createTab({
+ title: 'My blob',
+ url: {url: 'blob:null/foo'},
+ lastActiveTimeTicks: {internalValue: BigInt(4)},
+ })],
+ }],
+ }));
+ const tabSearchItem =
+ tabSearchPage.$.tabsList.querySelector('tab-search-item')!;
+ assertEquals(
+ loadTimeData.getString('blobUrlSource'), tabSearchItem.data.hostname);
+ });
});