Medium watch CVSS 4.3 chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInjection in Chrome Tabs
DescriptionInjection in Chrome Tabs
ComponentChrome Tabs
Sub-componentData_sharing
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.
Tracker504226770
Fix commitf46b3636d6ea (chromium/src) +42/-15
CWECWE-74
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-19 (per vendor advisory)
Fix landed2026-07-07 (public git)
Disclosed2026-08-25
Patch gap49 days — fix public in source before the advisory

Background

Shared tab groups / data sharing
a Chrome feature in components/data_sharing that lets a user preview and join a tab group someone else shared. The group is identified by a GroupToken, which pairs a GroupId (collaboration ID) with an opaque access_token.
`PreviewServerProxy::GetSharedDataPreview`
builds a request to the data-sharing preview server (.../collaborations/<id>/dataTypes/-/sharedEntities:preview) and sends it with an endpoint fetcher to fetch a preview of the shared group.
`GURL::Replacements::SetQueryStr`
replaces a URL’s whole query component with a raw string. Reserved characters such as & and = in that string are taken as structure, not escaped.
`net::AppendQueryParameter`
adds one name=value pair to a URL’s query and escapes the value, so the value stays a single parameter.

Root Cause Analysis

Before the patch, PreviewServerProxy::GetSharedDataPreview built its query by text substitution into the template "accessToken={token}&pageToken=&pageSize={pageSize}". It replaced {token} with the raw group_token.access_token using base::ReplaceFirstSubstringAfterOffset, then set the result with replacements.SetQueryStr(query_str). Because the token was not escaped, a token containing & or = broke out of the accessToken value and added or duplicated query parameters, such as pageSize or arbitrary extra keys, in the request to the preview server. The invariant that an opaque token always maps to exactly one accessToken parameter was broken.

The fix removes the template and builds the query with three net::AppendQueryParameter calls (accessToken, pageToken, pageSize). These escape each value, matching how DataSharingServiceImpl already serialises GroupToken fields. The new regression test confirms that the token "abc&pageSize=1&extra=1" comes back intact as one accessToken value, that no extra key appears, and that pageSize stays 550.

Key insight
An untrusted, opaque access token was inserted into a URL query string without escaping, so its & and = characters became parameter separators. Building the query with net::AppendQueryParameter escapes the value and confines it to a single parameter.

Attack Path

  1. Supply a crafted group token The attacker gets the victim’s Chrome to preview a shared tab group whose access_token contains reserved characters, for example through a crafted share or invite link the victim opens (inferred; the diff does not show where the token comes from).
  2. Chrome builds the preview URL GetSharedDataPreview substitutes the raw token into the query template and sets it with SetQueryStr, so the token’s &key=value pieces become separate query parameters.
  3. Injected parameters reach the server The request, sent through the proxy’s endpoint fetcher, carries attacker-chosen parameters such as an overriding pageSize or arbitrary extra keys alongside the real ones.
  4. Server-side effect Depending on how the preview server handles duplicate or extra parameters, the attacker may change the request’s meaning, for example its paging behaviour or other server-recognised options (inferred).

Impact Assessment

The bug is parameter injection into a request that the browser process sends to the data-sharing preview server. Any request sent with the user’s account credentials would let the attacker shape that request on the victim’s behalf (inferred). Concrete harm depends on server-side parameter handling, which the patch does not show. It needs the shared tab groups or data-sharing feature, plus a way to deliver a crafted group token to the victim. The advisory states only “Injection in Chrome Tabs” and gives no in-the-wild exploitation detail.

Changed Functions

FunctionChangeNotes
TEST_F
components/data_sharing/internal/preview_server_proxy_unittest.cc
modified

Files Changed

  • components/data_sharing/internal/preview_server_proxy.cc
  • components/data_sharing/internal/preview_server_proxy_unittest.cc

Audit Directions

  • String-template URL construction
    Search for ReplaceFirstSubstringAfterOffset, base::StringPrintf or string concatenation that builds a query or path from external values and is then passed to SetQueryStr, SetPathStr or GURL(...) without base::EscapeQueryParamValue or net::AppendQueryParameter.
  • Opaque server tokens from links
    Where tokens, IDs or invite codes from URLs, intents or sync data are sent back to Google endpoints (data sharing, collaboration, sync), check that each value is escaped for the URL component it goes into.
  • Tests that mask malformed inputs
    Look for unit tests whose expected URLs pass only because a later replacement overwrites a malformed component, as the old field-trial test did; such tests can hide injection bugs.
From f46b3636d6ea358a519be33702851e16d8e4a285 Mon Sep 17 00:00:00 2001
From: Jagadish C K <jagadishck@google.com>
Date: Tue, 07 Jul 2026 01:03:04 -0700
Subject: [PATCH] [data_sharing] Escape access token in preview request URL

PreviewServerProxy::GetSharedDataPreview() built the request query
string by substituting the raw access token into a template and
assigning the result via SetQueryStr(), so reserved characters in the
token were treated as query separators and could spill into additional
parameters. Build the query with net::AppendQueryParameter() instead,
matching how DataSharingServiceImpl already serializes the same
GroupToken fields.

Also fix FieldTrialPreviewServerProxyTest to pass a proper base URL for
the preview_service_base_url param; it previously passed the full
expected URL and only worked because SetQueryStr() overwrote the bogus
query component.

Bug: b:504226770
Change-Id: Ia4720dc90a96172595a0b8a59909f4a8d76d03e3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8016283
Commit-Queue: Jagadish C K (xWF) <jagadishck@google.com>
Reviewed-by: Chad Duffin <chadduffin@google.com>
Reviewed-by: Siddhartha S <ssid@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1657752}
---
diff --git a/components/data_sharing/internal/preview_server_proxy.cc b/components/data_sharing/internal/preview_server_proxy.cc
index 5b453d4..b8a9719d 100644
--- a/components/data_sharing/internal/preview_server_proxy.cc
+++ b/components/data_sharing/internal/preview_server_proxy.cc
@@ -26,6 +26,7 @@
 #include "components/sync/protocol/entity_specifics.pb.h"
 #include "components/sync/protocol/shared_tab_group_data_specifics.pb.h"
 #include "google_apis/common/base_requests.h"
+#include "net/base/url_util.h"
 #include "net/http/http_request_headers.h"
 #include "net/http/http_status_code.h"
 #include "net/traffic_annotation/network_traffic_annotation.h"
@@ -310,20 +311,10 @@
   std::string url_str = GetPreviewServerURLString();
   url_str.append("/").append(shared_entities_preview_path);
   GURL url = GURL(url_str);
-
-  // Query string in the URL to get shared entnties preview. {token} needs to
-  // be replaced by the caller. {pageSize} can be configured through finch.
-  const std::string kQueryString =
-      "accessToken={token}&pageToken=&pageSize={pageSize}";
-  std::string query_str = kQueryString;
-  base::ReplaceFirstSubstringAfterOffset(&query_str, 0, "{token}",
-                                         group_token.access_token);
-  base::ReplaceFirstSubstringAfterOffset(
-      &query_str, 0, "{pageSize}",
-      base::NumberToString(kPreviewDataSize.Get()));
-  GURL::Replacements replacements;
-  replacements.SetQueryStr(query_str);
-  url = url.ReplaceComponents(replacements);
+  url = net::AppendQueryParameter(url, "accessToken", group_token.access_token);
+  url = net::AppendQueryParameter(url, "pageToken", "");
+  url = net::AppendQueryParameter(url, "pageSize",
+                                  base::NumberToString(kPreviewDataSize.Get()));
   auto fetcher = CreateEndpointFetcher(url);
   auto* const fetcher_ptr = fetcher.get();
 
diff --git a/components/data_sharing/internal/preview_server_proxy_unittest.cc b/components/data_sharing/internal/preview_server_proxy_unittest.cc
index 750c588..140b05f 100644
--- a/components/data_sharing/internal/preview_server_proxy_unittest.cc
+++ b/components/data_sharing/internal/preview_server_proxy_unittest.cc
@@ -22,6 +22,7 @@
 #include "components/signin/public/identity_manager/identity_test_environment.h"
 #include "components/sync/base/command_line_switches.h"
 #include "components/sync/base/data_type.h"
+#include "net/base/url_util.h"
 #include "net/http/http_status_code.h"
 #include "services/data_decoder/public/cpp/test_support/in_process_data_decoder.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
@@ -60,6 +61,7 @@
     "collaborations/"
     "cmVzb3VyY2VzLzEyMzQ1NjcvZS8xMTExMTExMTExMTExMTE/dataTypes/-/"
     "sharedEntities:preview?accessToken=abcdefg&pageToken=&pageSize=550";
+const char kFieldTrialServiceBaseUrl[] = "https://test.com";
 const char kExpectedUrlFieldTrial[] =
     "https://test.com/"
     "collaborations/"
@@ -301,6 +303,40 @@
   QueryAndWaitForResponse(syncer::DataType::SHARED_TAB_GROUP_DATA);
 }
 
+TEST_F(PreviewServerProxyTest,
+       TestGetSharedDataPreview_AccessTokenWithReservedChars) {
+  // The access token is opaque to the client and may contain characters that
+  // are reserved in a URL query component. Ensure it is sent as a single
+  // `accessToken` query parameter rather than spilling into additional
+  // parameters.
+  const std::string kToken = "abc&pageSize=1&extra=1";
+  fetcher_->SetFetchResponse(kTabGroupResponse);
+
+  GURL request_url;
+  EXPECT_CALL(*server_proxy_, CreateEndpointFetcher(_))
+      .WillOnce([&](const GURL& url) {
+        request_url = url;
+        return std::move(fetcher_);
+      });
+
+  base::RunLoop run_loop;
+  server_proxy_->GetSharedDataPreview(
+      GroupToken(GroupId(kCollaborationId), kToken),
+      /*data_type=*/std::nullopt,
+      base::BindOnce(
+          [](const DataSharingService::SharedDataPreviewOrFailureOutcome&
+                 result) { ASSERT_TRUE(result.has_value()); })
+          .Then(run_loop.QuitClosure()));
+  run_loop.Run();
+
+  std::string value;
+  ASSERT_TRUE(net::GetValueForKeyInQuery(request_url, "accessToken", &value));
+  EXPECT_EQ(value, kToken);
+  EXPECT_FALSE(net::GetValueForKeyInQuery(request_url, "extra", &value));
+  ASSERT_TRUE(net::GetValueForKeyInQuery(request_url, "pageSize", &value));
+  EXPECT_EQ(value, "550");
+}
+
 TEST_F(PreviewServerProxyTest, TestGetSharedDataPreview_TabWithoutGroup) {
   fetcher_->SetFetchResponse(kTabResponse);
   EXPECT_CALL(*server_proxy_, CreateEndpointFetcher(GURL(kExpectedUrl)))
@@ -493,7 +529,7 @@
  public:
   base::FieldTrialParams GetFieldTrialParams() override {
     base::FieldTrialParams params;
-    params["preview_service_base_url"] = kExpectedUrlFieldTrial;
+    params["preview_service_base_url"] = kFieldTrialServiceBaseUrl;
     return params;
   }
 };
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/data_sharing/internal/preview_server_proxy_unittest.cc b/components/data_sharing/internal/preview_server_proxy_unittest.cc
index 750c588..140b05f 100644
--- a/components/data_sharing/internal/preview_server_proxy_unittest.cc
+++ b/components/data_sharing/internal/preview_server_proxy_unittest.cc
@@ -22,6 +22,7 @@
 #include "components/signin/public/identity_manager/identity_test_environment.h"
 #include "components/sync/base/command_line_switches.h"
 #include "components/sync/base/data_type.h"
+#include "net/base/url_util.h"
 #include "net/http/http_status_code.h"
 #include "services/data_decoder/public/cpp/test_support/in_process_data_decoder.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
@@ -60,6 +61,7 @@
     "collaborations/"
     "cmVzb3VyY2VzLzEyMzQ1NjcvZS8xMTExMTExMTExMTExMTE/dataTypes/-/"
     "sharedEntities:preview?accessToken=abcdefg&pageToken=&pageSize=550";
+const char kFieldTrialServiceBaseUrl[] = "https://test.com";
 const char kExpectedUrlFieldTrial[] =
     "https://test.com/"
     "collaborations/"
@@ -301,6 +303,40 @@
   QueryAndWaitForResponse(syncer::DataType::SHARED_TAB_GROUP_DATA);
 }
 
+TEST_F(PreviewServerProxyTest,
+       TestGetSharedDataPreview_AccessTokenWithReservedChars) {
+  // The access token is opaque to the client and may contain characters that
+  // are reserved in a URL query component. Ensure it is sent as a single
+  // `accessToken` query parameter rather than spilling into additional
+  // parameters.
+  const std::string kToken = "abc&pageSize=1&extra=1";
+  fetcher_->SetFetchResponse(kTabGroupResponse);
+
+  GURL request_url;
+  EXPECT_CALL(*server_proxy_, CreateEndpointFetcher(_))
+      .WillOnce([&](const GURL& url) {
+        request_url = url;
+        return std::move(fetcher_);
+      });
+
+  base::RunLoop run_loop;
+  server_proxy_->GetSharedDataPreview(
+      GroupToken(GroupId(kCollaborationId), kToken),
+      /*data_type=*/std::nullopt,
+      base::BindOnce(
+          [](const DataSharingService::SharedDataPreviewOrFailureOutcome&
+                 result) { ASSERT_TRUE(result.has_value()); })
+          .Then(run_loop.QuitClosure()));
+  run_loop.Run();
+
+  std::string value;
+  ASSERT_TRUE(net::GetValueForKeyInQuery(request_url, "accessToken", &value));
+  EXPECT_EQ(value, kToken);
+  EXPECT_FALSE(net::GetValueForKeyInQuery(request_url, "extra", &value));
+  ASSERT_TRUE(net::GetValueForKeyInQuery(request_url, "pageSize", &value));
+  EXPECT_EQ(value, "550");
+}
+
 TEST_F(PreviewServerProxyTest, TestGetSharedDataPreview_TabWithoutGroup) {
   fetcher_->SetFetchResponse(kTabResponse);
   EXPECT_CALL(*server_proxy_, CreateEndpointFetcher(GURL(kExpectedUrl)))
@@ -493,7 +529,7 @@
  public:
   base::FieldTrialParams GetFieldTrialParams() override {
     base::FieldTrialParams params;
-    params["preview_service_base_url"] = kExpectedUrlFieldTrial;
+    params["preview_service_base_url"] = kFieldTrialServiceBaseUrl;
     return params;
   }
 };
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.