Low watch CVSS 4.3 chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Split View
DescriptionInappropriate implementation in Split View
ComponentSplit View
Sub-componentSxS
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker446181124
Fix commite49ad13fa0e6 (chromium/src) +68/-19
CWECWE-290
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
CreditedKhalil Zhani
Reported2025-09-20 · bounty $1000 (per vendor advisory)
Fix landed2025-10-08 (public git)
Disclosed2025-12-02
Patch gap55 days — fix public in source before the advisory

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_url is the URL of the document actually showing. visible_url can 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:blank when it is false.
`getDisplayHostnameForUrl`
a helper that maps a URL to the label shown in the list. It uses localised strings for blob: and file: sources and, after this patch, about:blank for 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.

Key insight
Tab Search and Split View showed a tab’s uncommitted 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

  1. Create a tab with an uncommitted URL Attacker content causes a tab to have no valid committed URL while a pending navigation’s visible_url is set, for example a navigation that never commits (inferred; the diff does not show how the state is produced).
  2. Omnibox hides it, tab list does not For such a tab should_display_url is false and the omnibox shows about:blank, but before the patch the Tab Search handler still placed visible_url in tab_data->url.
  3. 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.
  4. User acts on spoofed label (inferred) A user could pick or trust that tab based on the misleading hostname (inferred).

Impact Assessment

The impact is limited to UI spoofing in browser-owned WebUI lists (Tab Search and the Split View tab picker): a tab could be labelled with a hostname it had not committed. There is no memory-safety or process-boundary effect. Reaching it needs a tab in an uncommitted-navigation state whose URL the browser would otherwise hide, plus the user opening Tab Search or Split View and relying on the label. The diff does not show whether Split View is enabled by default. The vendor severity is Low.

Changed Functions

FunctionChangeNotes
if
chrome/browser/resources/tab_search/split_view/app.ts
modified
if
chrome/browser/resources/tab_search/tab_data.ts
modified
if
chrome/browser/resources/tab_search/tab_search_page.ts
modified

Files Changed

  • chrome/browser/resources/tab_search/split_view/app.ts
  • chrome/browser/resources/tab_search/tab_data.ts
  • chrome/browser/resources/tab_search/tab_search_page.ts
  • chrome/browser/ui/webui/tab_search/tab_search_page_handler.cc
  • chrome/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_url or the pending entry without checking should_display_url or an equivalent.
  • Duplicated display-formatting logic
    Find places where URL-to-label formatting is re-implemented, as split_view/app.ts was, instead of using shared, security-reviewed helpers such as url_formatter or the omnibox’s elision rules.
  • New UI surfaces for existing tabs
    New 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:, and file: states.
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 =
Loading diff…

Regression Test / PoC

shipped with the fix
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 =
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.