CVE-2025-13636
Overview
Background
- Split View / Tab Search WebUI
- browser UI (
chrome/browser/resources/tab_search/) that lists open tabs with a hostname label. That includes the split-view new-tab picker (split_view/app.ts), where a user chooses a tab to place side by side. - Last committed vs. visible URL
last_committed_urlis the URL of the document actually showing.visible_urlcan reflect a pending, not-yet-committed navigation, which the browser shows only when it is safe to do so.- `tab_renderer_data.should_display_url`
- a flag in the tab-strip renderer data that says whether the tab’s visible URL should be shown to the user. Per the commit, the omnibox shows
about:blankwhen it is false. - `getDisplayHostnameForUrl`
- a helper that maps a URL to the label shown in the list. It uses localised strings for
blob:andfile:sources and, after this patch,about:blankfor blank pages.
Root Cause Analysis
When a tab had no valid committed URL, the tab serialisation in tab_search_page_handler.cc filled tab_data->url with tab_renderer_data.visible_url unconditionally. This ignored should_display_url, the signal the rest of the browser UI uses to decide whether an uncommitted URL is safe to show. The Tab Search and Split View lists could therefore show a hostname for a URL the tab had not actually committed, unlike the omnibox, which shows about:blank for these tabs according to the commit message.
The patch uses visible_url only when should_display_url is true and substitutes GURL(url::kAboutBlankURL) otherwise. On the WebUI side, split_view/app.ts previously used the raw new URL(...).hostname.
The patch moves the display-label logic into a shared exported getDisplayHostnameForUrl in tab_data.ts, with a new about:blank case, and uses it from both the split-view app and tab_search_page.ts.
As a result, blob:, file: and about:blank tabs get consistent, non-misleading labels.
visible_url without checking should_display_url, which made a URL-display inconsistency (spoof) possible against the omnibox’s behaviour. The fix applies the same rule as the omnibox: if the uncommitted URL should not be displayed, show about:blank.Attack Path
- Create a tab with an uncommitted URL
Attacker content causes a tab to have no valid committed URL while a pending navigation’s
visible_urlis set, for example a navigation that never commits (inferred; the diff does not show how the state is produced). - Omnibox hides it, tab list does not
For such a tab
should_display_urlis false and the omnibox showsabout:blank, but before the patch the Tab Search handler still placedvisible_urlintab_data->url. - Misleading label in Split View / Tab Search The split-view picker or Tab Search list shows the hostname of the uncommitted URL, which can suggest the tab belongs to a site it is not actually showing.
- User acts on spoofed label (inferred) A user could pick or trust that tab based on the misleading hostname (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/resources/tab_search/split_view/app.ts |
modified | |
ifchrome/browser/resources/tab_search/tab_data.ts |
modified | |
ifchrome/browser/resources/tab_search/tab_search_page.ts |
modified |
Files Changed
chrome/browser/resources/tab_search/split_view/app.tschrome/browser/resources/tab_search/tab_data.tschrome/browser/resources/tab_search/tab_search_page.tschrome/browser/ui/webui/tab_search/tab_search_page_handler.ccchrome/test/data/webui/tab_search/split_new_tab_page_test.ts
Audit Directions
- Other consumers of `visible_url`Look for other WebUI or views surfaces (tab hover cards, tab strip, history/recent tabs, sharing, Tab Groups, Reading List) that read
visible_urlor the pending entry without checkingshould_display_urlor an equivalent. - Duplicated display-formatting logicFind places where URL-to-label formatting is re-implemented, as
split_view/app.tswas, instead of using shared, security-reviewed helpers such asurl_formatteror the omnibox’s elision rules. - New UI surfaces for existing tabsNew features like Split View that list or preview tabs should get the same URL-spoofing review as the omnibox, especially for pending,
about:blank,blob:, andfile:states.
Patch
From e49ad13fa0e61b4b2acac9e524103325f10b0e21 Mon Sep 17 00:00:00 2001 From: Alison Gale <[email protected]> Date: Wed, 08 Oct 2025 16:20:46 -0700 Subject: [PATCH] [SxS] Handle uncommitted urls from split view and tab search For these URLs we display about:blank in the omnibox so this will be consistent. While I'm here I also shared the display URL logic so the split NTP will show when its a local file or something. Split NTP: https://screenshot.googleplex.com/BdMGxsXWM6BN3xp Tab search: https://screenshot.googleplex.com/7GCb8RuGuY7N49u Bug: 446181124 Change-Id: I42df102c3b3d4e51819bc89a3dc9549860178c16 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7017483 Commit-Queue: Alison Gale <[email protected]> Reviewed-by: Yuheng Huang <[email protected]> Reviewed-by: Chris Thompson <[email protected]> Cr-Commit-Position: refs/heads/main@{#1527218} --- diff --git a/chrome/browser/resources/tab_search/split_view/app.ts b/chrome/browser/resources/tab_search/split_view/app.ts index e86a8c4..1f2af3ac 100644 --- a/chrome/browser/resources/tab_search/split_view/app.ts +++ b/chrome/browser/resources/tab_search/split_view/app.ts @@ -11,7 +11,7 @@ import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js'; import type {SelectableLazyListElement} from '../selectable_lazy_list.js'; -import {normalizeURL, TabData, TabItemType} from '../tab_data.js'; +import {getDisplayHostnameForUrl, normalizeURL, TabData, TabItemType} from '../tab_data.js'; import type {ProfileData, Tab, TabsRemovedInfo, TabUpdateInfo} from '../tab_search.mojom-webui.js'; import type {TabSearchApiProxy} from '../tab_search_api_proxy.js'; import {TabSearchApiProxyImpl} from '../tab_search_api_proxy.js'; @@ -210,8 +210,9 @@ private getTabData_(tab: Tab, inActiveWindow: boolean, type: TabItemType): TabData { - const tabData = - new TabData(tab, type, new URL(normalizeURL(tab.url.url)).hostname); + const displayUrl = + getDisplayHostnameForUrl(new URL(normalizeURL(tab.url.url))); + const tabData = new TabData(tab, type, displayUrl); if (type === TabItemType.OPEN_TAB) { tabData.inActiveWindow = inActiveWindow; diff --git a/chrome/browser/resources/tab_search/tab_data.ts b/chrome/browser/resources/tab_search/tab_data.ts index 56f4a5d..5eb3d2e 100644 --- a/chrome/browser/resources/tab_search/tab_data.ts +++ b/chrome/browser/resources/tab_search/tab_data.ts @@ -118,6 +118,18 @@ return url || 'about:blank'; } +export function getDisplayHostnameForUrl(url: URL): string { + if (url.protocol === 'blob:') { + return loadTimeData.getString('blobUrlSource'); + } else if (url.protocol === 'file:') { + return loadTimeData.getString('fileUrlSource'); + } else if (url.protocol === 'about:' && url.pathname === 'blank') { + return 'about:blank'; + } else { + return url.hostname; + } +} + export function getTitle(data: TabData|TabGroupData): string|undefined { if (data.type === TabItemType.RECENTLY_CLOSED_TAB_GROUP) { return undefined; diff --git a/chrome/browser/resources/tab_search/tab_search_page.ts b/chrome/browser/resources/tab_search/tab_search_page.ts index 3dbd50776..c45a68c 100644 --- a/chrome/browser/resources/tab_search/tab_search_page.ts +++ b/chrome/browser/resources/tab_search/tab_search_page.ts @@ -26,7 +26,7 @@ import {search} from './search.js'; import type {SelectableLazyListElement} from './selectable_lazy_list.js'; import {NO_SELECTION, selectorNavigationKeys} from './selectable_lazy_list.js'; -import {ariaLabel, getHostname, getTabGroupTitle, getTitle, type ItemData, normalizeURL, TabData, TabGroupData, TabItemType, tokenEquals, tokenToString} from './tab_data.js'; +import {ariaLabel, getDisplayHostnameForUrl, getHostname, getTabGroupTitle, getTitle, type ItemData, normalizeURL, TabData, TabGroupData, TabItemType, tokenEquals, tokenToString} from './tab_data.js'; import type {ProfileData, RecentlyClosedTab, Tab, TabGroup, TabsRemovedInfo, TabUpdateInfo} from './tab_search.mojom-webui.js'; import {TabSearchSection} from './tab_search.mojom-webui.js'; import type {TabSearchApiProxy} from './tab_search_api_proxy.js'; @@ -661,22 +661,12 @@ 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, - this.getDisplayHostnameForUrl_(new URL(normalizeURL(tab.url.url)))); + 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_page_handler.cc b/chrome/browser/ui/webui/tab_search/tab_search_page_handler.cc index 36955759..e4580632 100644 --- a/chrome/browser/ui/webui/tab_search/tab_search_page_handler.cc +++ b/chrome/browser/ui/webui/tab_search/tab_search_page_handler.cc @@ -1428,10 +1428,13 @@ // A visible URL is used when the a new tab is still loading. // If it is cancelled during loading the visible URL becomes empty. // We will display an empty URL as about:blank in Javascript. - tab_data->url = - !last_committed_url.is_valid() || last_committed_url.is_empty() - ? tab_renderer_data.visible_url - : last_committed_url; + if (!last_committed_url.is_valid() || last_committed_url.is_empty()) { + tab_data->url = tab_renderer_data.should_display_url + ? tab_renderer_data.visible_url + : GURL(url::kAboutBlankURL); + } else { + tab_data->url = last_committed_url; + } if (tab_renderer_data.favicon.IsEmpty()) { tab_data->is_default_favicon = true; diff --git a/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts b/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts index 2de65aa8..2ccc569 100644 --- a/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts +++ b/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts @@ -114,6 +114,49 @@ assertEquals(3, tabSearchItems.length); }); + test('Formats urls properly', async () => { + await splitNewTabPageSetup(); + + const windowData = createWindowData(); + windowData[0]!.tabs.push( + createTab({ + index: 6, + lastActiveTimeTicks: {internalValue: BigInt(10)}, + tabId: 8, + title: '', + url: {url: 'about:blank'}, + }), + createTab({ + index: 7, + lastActiveTimeTicks: {internalValue: BigInt(11)}, + tabId: 9, + title: 'file.jpg', + url: {url: 'file://file.jpg'}, + }), + createTab({ + index: 8, + lastActiveTimeTicks: {internalValue: BigInt(12)}, + tabId: 10, + title: 'Data', + url: {url: 'blob://data'}, + }), + ); + testApiProxy.getCallbackRouterRemote().tabsChanged(createProfileData({ + windows: windowData, + })); + await eventToPromise('viewport-filled', splitNewTabPage.$.splitTabsList); + + const tabSearchItems = + splitNewTabPage.shadowRoot.querySelectorAll('tab-search-item'); + assertEquals( + loadTimeData.getString('blobUrlSource'), + tabSearchItems[1]!.data.hostname); + assertEquals( + loadTimeData.getString('fileUrlSource'), + tabSearchItems[2]!.data.hostname); + assertEquals('about:blank', tabSearchItems[3]!.data.hostname); + }); + test('Sorts list', async () => { await splitNewTabPageSetup(); const tabSearchItems =
Regression Test / PoC
diff --git a/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts b/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts
index 2de65aa8..2ccc569 100644
--- a/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts
+++ b/chrome/test/data/webui/tab_search/split_new_tab_page_test.ts
@@ -114,6 +114,49 @@
assertEquals(3, tabSearchItems.length);
});
+ test('Formats urls properly', async () => {
+ await splitNewTabPageSetup();
+
+ const windowData = createWindowData();
+ windowData[0]!.tabs.push(
+ createTab({
+ index: 6,
+ lastActiveTimeTicks: {internalValue: BigInt(10)},
+ tabId: 8,
+ title: '',
+ url: {url: 'about:blank'},
+ }),
+ createTab({
+ index: 7,
+ lastActiveTimeTicks: {internalValue: BigInt(11)},
+ tabId: 9,
+ title: 'file.jpg',
+ url: {url: 'file://file.jpg'},
+ }),
+ createTab({
+ index: 8,
+ lastActiveTimeTicks: {internalValue: BigInt(12)},
+ tabId: 10,
+ title: 'Data',
+ url: {url: 'blob://data'},
+ }),
+ );
+ testApiProxy.getCallbackRouterRemote().tabsChanged(createProfileData({
+ windows: windowData,
+ }));
+ await eventToPromise('viewport-filled', splitNewTabPage.$.splitTabsList);
+
+ const tabSearchItems =
+ splitNewTabPage.shadowRoot.querySelectorAll('tab-search-item');
+ assertEquals(
+ loadTimeData.getString('blobUrlSource'),
+ tabSearchItems[1]!.data.hostname);
+ assertEquals(
+ loadTimeData.getString('fileUrlSource'),
+ tabSearchItems[2]!.data.hostname);
+ assertEquals('about:blank', tabSearchItems[3]!.data.hostname);
+ });
+
test('Sorts list', async () => {
await splitNewTabPageSetup();
const tabSearchItems =