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 <agale@chromium.org>
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 <agale@chromium.org>
Reviewed-by: Yuheng Huang <yuhengh@chromium.org>
Reviewed-by: Chris Thompson <cthomp@chromium.org>
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 =