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 <agale@chromium.org>
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 <cthomp@chromium.org>
Reviewed-by: Yuheng Huang <yuhengh@chromium.org>
Commit-Queue: Alison Gale <agale@chromium.org>
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);
+ });
});