Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Tab Group Sync
DescriptionInsufficient validation of untrusted input in Tab Group Sync
ComponentTab Group Sync
Bug ClassLogic Error
Tracker497934980
Fix commit7a6046a8a286 (chromium/src) +50/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
TEST_F
components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
modified

Files Changed

  • chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelper.java
  • chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelperUnitTest.java
  • components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
  • components/saved_tab_groups/internal/saved_tab_group_proto_conversions.cc
From 7a6046a8a286024c89f0dd321c94646f4d7a3ebf Mon Sep 17 00:00:00 2001
From: Calder Kitagawa <ckitagawa@chromium.org>
Date: Wed, 08 Apr 2026 14:31:43 -0700
Subject: [PATCH] [Tab Group Sync] Harden invalid URL handling

Fixed: 497934980
Change-Id: I3114fd04ad2ed5f09a70cd7a7f4ba40d502f6ca1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7738373
Reviewed-by: Darryl James <dljames@chromium.org>
Auto-Submit: Calder Kitagawa <ckitagawa@chromium.org>
Commit-Queue: Darryl James <dljames@chromium.org>
Commit-Queue: Calder Kitagawa <ckitagawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1611784}
---

diff --git a/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelper.java b/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelper.java
index 7d2bbea6..bdff0cd 100644
--- a/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelper.java
+++ b/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelper.java
@@ -306,7 +306,9 @@
             return;
         }
 
-        if (TabGroupSyncUtils.isUrlInTabRedirectChain(tab, url)) {
+        // Don't apply redirect chain URLs or non-savable URLs.
+        if (TabGroupSyncUtils.isUrlInTabRedirectChain(tab, syncUrl)
+                || !TabGroupSyncUtils.isSavableUrl(syncUrl)) {
             return;
         }
 
diff --git a/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelperUnitTest.java b/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelperUnitTest.java
index 0240b7c..2f8f453 100644
--- a/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelperUnitTest.java
+++ b/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelperUnitTest.java
@@ -303,6 +303,23 @@
     }
 
     @Test
+    public void testUpdateTabGroup_UpdateExistingTab_NonSavableUrlIsIgnored() {
+        // One local group with one tab syncing.
+        addOneTab();
+
+        // One saved group with one tabs mapped to the local tab.
+        SavedTabGroup savedTabGroup =
+                createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {TAB_ID_1});
+        SavedTabGroupTab savedTab = savedTabGroup.savedTabs.get(0);
+        savedTab.url = UNSYNCABLE_URL_1;
+
+        mLocalMutationHelper.updateTabGroup(savedTabGroup);
+
+        verify(mTabCreationDelegate, never())
+                .navigateToUrl(any(), any(), anyString(), anyBoolean());
+    }
+
+    @Test
     public void testUpdateTabGroup_UpdateExistingTab_UnsyncableUrlAreNotClobberedWithNTPUrl() {
         // One local group with one tab syncing.
         addOneTab();
diff --git a/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc b/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
index 69d2366..dd736530 100644
--- a/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
+++ b/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
@@ -139,7 +139,7 @@
 }
 
 TEST_F(SavedTabGroupConversionTest, TabToDataRetainsData) {
-  SavedTabGroupTab tab(GURL("chrome://hidden_link"), u"Hidden Title",
+  SavedTabGroupTab tab(GURL("https://www.google.com"), u"Google",
                        base::Uuid::GenerateRandomV4(), /*position=*/0,
                        base::Uuid::GenerateRandomV4(), std::nullopt,
                        std::nullopt, std::nullopt, time_, time_);
@@ -178,6 +178,28 @@
           .specifics());
 }
 
+TEST_F(SavedTabGroupConversionTest, DataToTabWithInvalidURLFallback) {
+  proto::SavedTabGroupData pb_data;
+  sync_pb::SavedTabGroupSpecifics* pb_specific = pb_data.mutable_specifics();
+  pb_specific->set_guid(base::Uuid::GenerateRandomV4().AsLowercaseString());
+
+  int64_t time_in_micros = time_.ToDeltaSinceWindowsEpoch().InMicroseconds();
+  pb_specific->set_creation_time_windows_epoch_micros(time_in_micros);
+  pb_specific->set_update_time_windows_epoch_micros(time_in_micros);
+
+  sync_pb::SavedTabGroupTab* pb_tab = pb_specific->mutable_tab();
+  pb_tab->set_url("invalid_url");
+  pb_tab->set_group_guid(base::Uuid::GenerateRandomV4().AsLowercaseString());
+  pb_tab->set_title("Invalid URL Title");
+
+  SavedTabGroupTab tab =
+      SavedTabGroupSyncBridge::DataToSavedTabGroupTabForTest(pb_data);
+
+  auto [default_url, default_title] = GetDefaultUrlAndTitle();
+  EXPECT_EQ(tab.url(), default_url);
+  EXPECT_EQ(tab.title(), default_title);
+}
+
 TEST_F(SavedTabGroupConversionTest, DataToTabRetainsData) {
   proto::SavedTabGroupData pb_data;
   sync_pb::SavedTabGroupSpecifics* pb_specific = pb_data.mutable_specifics();
diff --git a/components/saved_tab_groups/internal/saved_tab_group_proto_conversions.cc b/components/saved_tab_groups/internal/saved_tab_group_proto_conversions.cc
index 09be1b0..7561d7d 100644
--- a/components/saved_tab_groups/internal/saved_tab_group_proto_conversions.cc
+++ b/components/saved_tab_groups/internal/saved_tab_group_proto_conversions.cc
@@ -349,9 +349,14 @@
   std::optional<std::string> last_updater_cache_guid =
       GetLastUpdaterCacheGuidFromSpecifics(specific);
 
+  GURL url(specific.tab().url());
+  std::u16string title = base::UTF8ToUTF16(specific.tab().title());
+  if (!IsURLValidForSavedTabGroups(url)) {
+    std::tie(url, title) = GetDefaultUrlAndTitle();
+  }
+
   SavedTabGroupTab tab(
-      GURL(specific.tab().url()), base::UTF8ToUTF16(specific.tab().title()),
-      base::Uuid::ParseLowercase(specific.tab().group_guid()),
+      url, title, base::Uuid::ParseLowercase(specific.tab().group_guid()),
       specific.tab().position(), base::Uuid::ParseLowercase(specific.guid()),
       std::nullopt, std::move(creator_cache_guid),
       std::move(last_updater_cache_guid), creation_time, update_time,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc b/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
index 69d2366..dd736530 100644
--- a/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
+++ b/components/saved_tab_groups/internal/saved_tab_group_proto_conversion_unittest.cc
@@ -139,7 +139,7 @@
 }
 
 TEST_F(SavedTabGroupConversionTest, TabToDataRetainsData) {
-  SavedTabGroupTab tab(GURL("chrome://hidden_link"), u"Hidden Title",
+  SavedTabGroupTab tab(GURL("https://www.google.com"), u"Google",
                        base::Uuid::GenerateRandomV4(), /*position=*/0,
                        base::Uuid::GenerateRandomV4(), std::nullopt,
                        std::nullopt, std::nullopt, time_, time_);
@@ -178,6 +178,28 @@
           .specifics());
 }
 
+TEST_F(SavedTabGroupConversionTest, DataToTabWithInvalidURLFallback) {
+  proto::SavedTabGroupData pb_data;
+  sync_pb::SavedTabGroupSpecifics* pb_specific = pb_data.mutable_specifics();
+  pb_specific->set_guid(base::Uuid::GenerateRandomV4().AsLowercaseString());
+
+  int64_t time_in_micros = time_.ToDeltaSinceWindowsEpoch().InMicroseconds();
+  pb_specific->set_creation_time_windows_epoch_micros(time_in_micros);
+  pb_specific->set_update_time_windows_epoch_micros(time_in_micros);
+
+  sync_pb::SavedTabGroupTab* pb_tab = pb_specific->mutable_tab();
+  pb_tab->set_url("invalid_url");
+  pb_tab->set_group_guid(base::Uuid::GenerateRandomV4().AsLowercaseString());
+  pb_tab->set_title("Invalid URL Title");
+
+  SavedTabGroupTab tab =
+      SavedTabGroupSyncBridge::DataToSavedTabGroupTabForTest(pb_data);
+
+  auto [default_url, default_title] = GetDefaultUrlAndTitle();
+  EXPECT_EQ(tab.url(), default_url);
+  EXPECT_EQ(tab.title(), default_title);
+}
+
 TEST_F(SavedTabGroupConversionTest, DataToTabRetainsData) {
   proto::SavedTabGroupData pb_data;
   sync_pb::SavedTabGroupSpecifics* pb_specific = pb_data.mutable_specifics();
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential UXSS on Android via javascript: URL injection in Tab Group Sync

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A lack of URL scheme validation in Android’s Saved Tab Group Sync allows an attacker to inject javascript: URLs into a user’s synced tab group. During startup reconciliation, the browser matches the malicious synced tab to an existing local tab by position and navigates it, resulting in Universal Cross-Site Scripting (UXSS).

Affected files:

  • chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/LocalTabGroupMutationHelper.java
  • components/saved_tab_groups/internal/saved_tab_group_sync_bridge.cc
  • components/saved_tab_groups/internal/saved_tab_group_proto_conversions.cc
  • chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/StartupHelper.java

Estimated timestamp from git blame: 2025-07-09

Description

There is a potential Universal Cross-Site Scripting (UXSS) vulnerability on Android due to missing URL scheme validation in the Saved Tab Group Sync feature. If an attacker controls a victim’s sync data, they can inject a javascript: URL that Chrome will force an existing, sensitive local tab to load upon startup.

The vulnerability stems from the intersection of three behaviors:

  1. Unvalidated Sync Ingestion: When SavedTabGroupSyncBridge processes remote sync data for a new tab in an existing group (AddDataToLocalStorage), it bypasses the validation in MergeRemoteTab. Instead, it calls DataToSavedTabGroupTab, which blindly constructs a SavedTabGroupTab with the attacker’s javascript: URL.
  2. Position-Based Reconciliation: On Android startup, LocalTabGroupMutationHelper.reconcileGroupOnStartup matches remote synced tabs to local tabs strictly by their index/position (isOnStartup == true), ignoring tab IDs. An attacker’s tab at position 0 will match the victim’s local tab at position 0.
  3. Flawed URL Override: When updating the matched tab, LocalTabGroupMutationHelper.maybeNavigateToUrl checks if the local URL is “savable” (e.g., HTTP/HTTPS). If it is, it unconditionally accepts the sync URL and calls tab.loadUrl("javascript:...").

When tab.loadUrl is called with a javascript: URL, NavigationControllerImpl::LoadURLWithParams treats it as a renderer debug URL. It is routed directly to LocalFrame::LoadJavaScriptURL in the renderer process, where the script executes in the context of the currently loaded page (e.g., https://bank.com) with Content Security Policy bypassed (DO_NOT_CHECK).

Note: The following are suggested/potential steps to reproduce. Our tooling agent has traced this via code analysis but does not run live code payloads.

Potential Steps to Reproduce

  1. The victim has a saved tab group on an Android device, with the active tab at position 0 navigated to a sensitive origin (e.g., https://bank.com).
  2. The victim backgrounds Chrome, stopping the Activity.
  3. The attacker (having compromised the sync account or a connected device) pushes a new SavedTabGroupTab entity to the sync server for the same group.
  4. The malicious entity is configured with url = 'javascript:alert(document.domain)' and position = 0.
  5. The victim brings Chrome to the foreground. StartupHelper.initializeTabGroupSync() triggers reconciliation.
  6. LocalTabGroupMutationHelper matches the existing bank.com tab with the attacker’s injected tab based solely on its position (0).
  7. maybeNavigateToUrl is invoked and passes the javascript: URL to the bank.com tab via tab.loadUrl().
  8. The JavaScript executes in the context of https://bank.com, achieving UXSS.

Suggested Fix

  1. Strict URL Validation on Ingestion: Ensure DataToSavedTabGroupTab (or AddDataToLocalStorage before calling it) enforces IsURLValidForSavedTabGroups() similar to how MergeRemoteTab does. If the URL is invalid, it should be sanitized (e.g., replaced with kChromeSavedTabGroupUnsupportedURL).
  2. Safe Navigation: Add scheme allowlist checks to LocalTabGroupMutationHelper.maybeNavigateToUrl before initiating a navigation from synced data to ensure that javascript:, chrome:, or file: URLs are never loaded.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker
Links in the report