Medium watch CVSS 5.9 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.9
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Passwords
DescriptionImproper input validation in Passwords
ComponentPasswords
Sub-componentPasswords
Bug ClassInput Validation
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.
Tracker502814490
Fix commit74679c5c176b (chromium/src) +82/-1
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-15 (per vendor advisory)
Fix landed2026-07-31 (public git)
Disclosed2026-09-08
Patch gap39 days — fix public in source before the advisory

Background

Password sharing invitation
a Sync-delivered message that lets one user send a saved credential to another, carrying fields like url, username_value, password_value, and display metadata.
`PasswordForm::icon_url`
the URL of a favicon/avatar image shown in the password manager UI for a credential, fetched and rendered by the receiving client.
`avatar_url`
the attacker-controllable string field inside the invitation’s password_group_element_data that is copied verbatim into PasswordForm::icon_url.
Same-origin
a scheme/host/port match; url::IsSameOriginWith compares two GURLs to enforce that the icon is served from the credential’s own origin.

Root Cause Analysis

When a client processes an incoming password sharing invitation, the invitation-to-PasswordForm conversion path copied the sender-supplied avatar_url directly into form.icon_url via form.icon_url = GURL(password_group_element_data.avatar_url()) with no validation of scheme or origin. Because the resulting icon_url is later fetched to render the credential’s icon, an attacker who sends (or is auto-approved for) a sharing invitation could point it at an arbitrary cross-origin URL, turning the image fetch into a cross-user tracking beacon (and allowing non-HTTP schemes such as javascript: to be stored). The invariant that a credential’s icon must be same-origin with the credential itself and use a web scheme was never enforced on this untrusted, Sync-transported input.

The fix adds IsValidIconUrlForSharedCredential, which returns true only when the icon URL is empty, or is valid, SchemeIsHTTPOrHTTPS, and same-origin with the credential’s url. It is applied at two points: the conversion path now clears an invalid icon_url to an empty GURL(), and IsValidSharedPasswordForm now rejects a form whose icon_url fails the check, so a tampered stored form is not accepted.

Key insight
The single mistake was trusting a sender-controlled avatar_url as a credential icon without checking its scheme or origin, letting it become an arbitrary outbound-fetch URL; the fix constrains it to empty-or-(HTTP/HTTPS-and-same-origin) and strips anything else.

Attack Path

  1. Craft a malicious invitation An attacker constructs a password sharing invitation whose password_group_element_data.avatar_url points at an attacker-controlled cross-origin endpoint (e.g. https://attacker-beacon.test/track.png) or a non-web scheme.
  2. Deliver via Sync The invitation reaches the victim’s client through the password sharing sync channel and enters ProcessIncomingSharingInvitation.
  3. Unvalidated copy into the form The pre-patch conversion code sets form.icon_url = GURL(avatar_url()) with no origin/scheme check, so the attacker’s URL is persisted on the stored PasswordForm.
  4. Icon fetch as tracking beacon When the password manager UI renders the shared credential, it fetches icon_url, causing the victim’s client to make an outbound request to the attacker’s endpoint, leaking presence/timing and enabling cross-user tracking (inferred: exact fetch/render site is downstream of the diff).

Impact Assessment

An attacker who can send an auto-approved password sharing invitation gains an attacker-chosen cross-origin (or non-HTTP) URL stored as a credential’s icon, which the victim’s browser will fetch, yielding a cross-user tracking/beacon primitive rather than memory corruption. The vulnerability is client-side in the receiving profile’s password manager and requires the attacker to reach the victim over the password sharing sync channel. This is an input-validation/privacy issue; no code execution or sandbox escape is demonstrated by the patch, and the advisory reports it only as improper input validation.

Changed Functions

FunctionChangeNotes
PasswordReceiverServiceImplIconUrlTest
components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
modified
TEST_P
components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
modified

Files Changed

  • components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
  • components/password_manager/core/browser/sharing/password_receiver_service_impl.cc
  • chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
  • components/password_manager/core/browser/sharing/BUILD.gn

Audit Directions

  • Sender-controlled URL fields
    Audit every field in Sync-delivered or shared data (avatar/image/icon/profile URLs, deep links) that is copied into a GURL and later fetched or navigated, and confirm scheme and origin are validated before use.
  • Validate-at-both-gates
    Where untrusted input is written to a stored object in one place and validated in an acceptance predicate in another, check that both the writer sanitizes and the predicate rejects, since either path alone can admit tainted data.
  • Icon/beacon fetches in privacy-sensitive UI
    Review UI surfaces that auto-fetch remote images for cross-user content (password sharing, contacts, notifications) for tracking-beacon risk from attacker-chosen hosts and non-HTTP schemes.
From 74679c5c176b6e3eb44f379a16f7905b3522ba5e Mon Sep 17 00:00:00 2001
From: Adem Derinel <derinel@google.com>
Date: Thu, 30 Jul 2026 23:10:02 -0700
Subject: [PATCH] Passwords: Validate icon URL in incoming sharing invitations

Validate that icon_url in incoming sharing invitations is same-origin
with the credential origin, and uses HTTP/HTTPS. Clear it otherwise.

Fixed: 502814490
Change-Id: I6e2b28c599f498f3f4cc216d84f4822d22218f6a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8166860
Reviewed-by: Rafał Godlewski <rgod@google.com>
Reviewed-by: Mahmoud Rashad <mmrashad@google.com>
Commit-Queue: Adem Derinel <derinel@google.com>
Cr-Commit-Position: refs/heads/main@{#1671639}
---
diff --git a/components/password_manager/core/browser/sharing/password_receiver_service_impl.cc b/components/password_manager/core/browser/sharing/password_receiver_service_impl.cc
index 7678402f..8417be5 100644
--- a/components/password_manager/core/browser/sharing/password_receiver_service_impl.cc
+++ b/components/password_manager/core/browser/sharing/password_receiver_service_impl.cc
@@ -21,6 +21,7 @@
 #include "components/password_manager/core/browser/sharing/incoming_password_sharing_invitation_sync_bridge.h"
 #include "components/sync/model/data_type_controller_delegate.h"
 #include "components/sync/service/sync_service.h"
+#include "url/gurl.h"
 #include "url/origin.h"
 
 namespace password_manager {
@@ -40,6 +41,19 @@
   return str.size() <= kMaxString16Length;
 }
 
+// Returns true if `icon_url` is empty, or if it is valid, HTTP/HTTPS, and
+// same-origin with `credential_url`. If it is cross-origin or invalid, it is
+// not a valid icon URL for the shared credential and should be stripped to
+// prevent cross-user tracking beacons.
+bool IsValidIconUrlForSharedCredential(const GURL& icon_url,
+                                       const GURL& credential_url) {
+  if (icon_url.is_empty()) {
+    return true;
+  }
+  return icon_url.is_valid() && icon_url.SchemeIsHTTPOrHTTPS() &&
+         url::IsSameOriginWith(icon_url, credential_url);
+}
+
 bool IsValidSharedPasswordForm(const PasswordForm& form) {
   if (!form.url.is_valid() || form.url.is_empty()) {
     return false;
@@ -70,6 +84,9 @@
       !form.sender_profile_image_url.is_valid()) {
     return false;
   }
+  if (!IsValidIconUrlForSharedCredential(form.icon_url, form.url)) {
+    return false;
+  }
   return true;
 }
 
@@ -141,6 +158,9 @@
     form.display_name =
         base::UTF8ToUTF16(password_group_element_data.display_name());
     form.icon_url = GURL(password_group_element_data.avatar_url());
+    if (!IsValidIconUrlForSharedCredential(form.icon_url, form.url)) {
+      form.icon_url = GURL();
+    }
     form.date_created = base::Time::Now();
     form.type = PasswordForm::Type::kReceivedViaSharing;
     form.skip_zero_click = true;
diff --git a/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc b/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
index 8936cb87..cada92bb 100644
--- a/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
+++ b/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
@@ -21,7 +21,7 @@
 constexpr char kUsernameElement[] = "username_element";
 constexpr char kPasswordElement[] = "password_element";
 constexpr char kPasswordDisplayName[] = "password_display_name";
-constexpr char kPasswordAvatarUrl[] = "http://avatar.url/";
+constexpr char kPasswordAvatarUrl[] = "http://abc.com/avatar.png";
 
 constexpr char kSenderEmail[] = "sender@gmail.com";
 constexpr char kSenderDisplayName[] = "Sender Name";
diff --git a/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc b/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
index fb0e8f0..8830575d 100644
--- a/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
+++ b/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
@@ -31,6 +31,8 @@
 #include "components/sync/test/test_sync_service.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+#include "url/origin.h"
 
 namespace password_manager {
 
@@ -709,4 +711,62 @@
       1);
 }
 
+struct IconUrlTestCase {
+  std::string test_name;
+  std::string input_url;
+  std::string expected_url;
+};
+
+class PasswordReceiverServiceImplIconUrlTest
+    : public PasswordReceiverServiceImplTest,
+      public testing::WithParamInterface<IconUrlTestCase> {};
+
+TEST_P(PasswordReceiverServiceImplIconUrlTest, ProcessInvitation) {
+  base::HistogramTester histogram_tester;
+  sync_pb::IncomingPasswordSharingInvitationSpecifics invitation =
+      CreateIncomingSharingInvitation();
+
+  invitation.mutable_client_only_unencrypted_data()
+      ->mutable_password_group_data()
+      ->mutable_element_data(0)
+      ->set_avatar_url(GetParam().input_url);
+
+  password_receiver_service()->ProcessIncomingSharingInvitation(invitation);
+  RunUntilIdle();
+
+  ASSERT_TRUE(GetAllLoginsSync(&expected_password_store_for_syncing())
+                  .contains(GetInvitationOrigin(invitation)));
+  EXPECT_THAT(GetAllLoginsSync(&expected_password_store_for_syncing())
+                  .at(GetInvitationOrigin(invitation)),
+              ElementsAre(AllOf(
+                  Field(&PasswordForm::signon_realm, kUrl),
+                  Field(&PasswordForm::username_value, kUsername),
+                  Field(&PasswordForm::password_value, kPassword),
+                  Field(&PasswordForm::icon_url, GURL(GetParam().expected_url)))));
+
+  histogram_tester.ExpectUniqueSample(
+      "PasswordManager.ProcessIncomingPasswordSharingInvitationResult",
+      metrics_util::ProcessIncomingPasswordSharingInvitationResult::
+          kInvitationAutoApproved,
+      1);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    All,
+    PasswordReceiverServiceImplIconUrlTest,
+    testing::Values(
+        IconUrlTestCase{
+            .test_name = "AcceptSameOrigin",
+            .input_url = "https://www.test.com/favicon.ico",
+            .expected_url = "https://www.test.com/favicon.ico"},
+        IconUrlTestCase{.test_name = "ClearCrossOrigin",
+                        .input_url = "https://attacker-beacon.test/track.png",
+                        .expected_url = ""},
+        IconUrlTestCase{.test_name = "ClearInvalid",
+                        .input_url = "javascript:alert(1)",
+                        .expected_url = ""}),
+    [](const testing::TestParamInfo<IconUrlTestCase>& info) {
+      return info.param.test_name;
+    });
+
 }  // namespace password_manager
diff --git a/components/password_manager/core/browser/sharing/BUILD.gn b/components/password_manager/core/browser/sharing/BUILD.gn
index 2ffd7c1..afd25e5 100644
--- a/components/password_manager/core/browser/sharing/BUILD.gn
+++ b/components/password_manager/core/browser/sharing/BUILD.gn
@@ -90,5 +90,6 @@
     "//components/sync/protocol",
     "//testing/gmock",
     "//testing/gtest",
+    "//url",
   ]
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc b/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
index 8936cb87..cada92bb 100644
--- a/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
+++ b/chrome/browser/sync/test/integration/password_sharing_invitation_helper.cc
@@ -21,7 +21,7 @@
 constexpr char kUsernameElement[] = "username_element";
 constexpr char kPasswordElement[] = "password_element";
 constexpr char kPasswordDisplayName[] = "password_display_name";
-constexpr char kPasswordAvatarUrl[] = "http://avatar.url/";
+constexpr char kPasswordAvatarUrl[] = "http://abc.com/avatar.png";
 
 constexpr char kSenderEmail[] = "sender@gmail.com";
 constexpr char kSenderDisplayName[] = "Sender Name";
diff --git a/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc b/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
index fb0e8f0..8830575d 100644
--- a/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
+++ b/components/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
@@ -31,6 +31,8 @@
 #include "components/sync/test/test_sync_service.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+#include "url/origin.h"
 
 namespace password_manager {
 
@@ -709,4 +711,62 @@
       1);
 }
 
+struct IconUrlTestCase {
+  std::string test_name;
+  std::string input_url;
+  std::string expected_url;
+};
+
+class PasswordReceiverServiceImplIconUrlTest
+    : public PasswordReceiverServiceImplTest,
+      public testing::WithParamInterface<IconUrlTestCase> {};
+
+TEST_P(PasswordReceiverServiceImplIconUrlTest, ProcessInvitation) {
+  base::HistogramTester histogram_tester;
+  sync_pb::IncomingPasswordSharingInvitationSpecifics invitation =
+      CreateIncomingSharingInvitation();
+
+  invitation.mutable_client_only_unencrypted_data()
+      ->mutable_password_group_data()
+      ->mutable_element_data(0)
+      ->set_avatar_url(GetParam().input_url);
+
+  password_receiver_service()->ProcessIncomingSharingInvitation(invitation);
+  RunUntilIdle();
+
+  ASSERT_TRUE(GetAllLoginsSync(&expected_password_store_for_syncing())
+                  .contains(GetInvitationOrigin(invitation)));
+  EXPECT_THAT(GetAllLoginsSync(&expected_password_store_for_syncing())
+                  .at(GetInvitationOrigin(invitation)),
+              ElementsAre(AllOf(
+                  Field(&PasswordForm::signon_realm, kUrl),
+                  Field(&PasswordForm::username_value, kUsername),
+                  Field(&PasswordForm::password_value, kPassword),
+                  Field(&PasswordForm::icon_url, GURL(GetParam().expected_url)))));
+
+  histogram_tester.ExpectUniqueSample(
+      "PasswordManager.ProcessIncomingPasswordSharingInvitationResult",
+      metrics_util::ProcessIncomingPasswordSharingInvitationResult::
+          kInvitationAutoApproved,
+      1);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    All,
+    PasswordReceiverServiceImplIconUrlTest,
+    testing::Values(
+        IconUrlTestCase{
+            .test_name = "AcceptSameOrigin",
+            .input_url = "https://www.test.com/favicon.ico",
+            .expected_url = "https://www.test.com/favicon.ico"},
+        IconUrlTestCase{.test_name = "ClearCrossOrigin",
+                        .input_url = "https://attacker-beacon.test/track.png",
+                        .expected_url = ""},
+        IconUrlTestCase{.test_name = "ClearInvalid",
+                        .input_url = "javascript:alert(1)",
+                        .expected_url = ""}),
+    [](const testing::TestParamInfo<IconUrlTestCase>& info) {
+      return info.param.test_name;
+    });
+
 }  // namespace password_manager
Loading diff…

Original Bug Report

reported by vm...@google.com

Missing icon_url validation in password sharing allows potential cross-user tracking

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A privacy vulnerability in Chrome’s password sharing feature allows a malicious Google Family member to potentially track when another family member visits specific third-party websites. This occurs because the browser process fails to validate the icon URL of a shared credential during ingestion, allowing it to act as a tracking beacon.

Affected files:

  • components/password_manager/core/browser/sharing/password_receiver_service_impl.cc
  • chrome/browser/ui/views/passwords/credentials_item_view.cc
  • chrome/browser/ui/passwords/account_avatar_fetcher.cc

Estimated timestamp from git blame: 2026-04-14

Summary

A privacy vulnerability in the password sharing ingestion path allows a malicious Google Family member to potentially track the browsing activity of another family member. This is achieved by sending a password sharing invitation containing a crafted avatar_url (mapped to icon_url). Because the invitation’s payload is end-to-end encrypted, the sync server cannot inspect or sanitize the URL. On the recipient’s side, the browser process fails to validate the icon_url during ingestion, leading to the storage of a credential with an arbitrary tracking beacon URL.

Technical Details

When a password sharing invitation is received via the sync bridge, it is decrypted and processed by PasswordReceiverServiceImpl::ProcessIncomingSharingInvitation. The sync protobuf message is converted into a PasswordForm object via IncomingSharingInvitationToPasswordForms.

During this conversion, the attacker-controlled avatar_url from the encrypted payload is assigned directly to the form.icon_url field:

// components/password_manager/core/browser/sharing/password_receiver_service_impl.cc:140
form.icon_url = GURL(password_group_element_data.avatar_url());

The resulting PasswordForm is then validated by IsValidSharedPasswordForm(). While this function performs checks on the url, signon_realm, and sender_profile_image_url, it entirely omits validation for form.icon_url.

bool IsValidSharedPasswordForm(const PasswordForm& form) {
  // ... (validates form.url, username, password, signon_realm)
  if (!form.sender_profile_image_url.is_empty() &&
      !form.sender_profile_image_url.is_valid()) {
    return false;
  }
  return true; // Missing validation for form.icon_url
}

Because the URL is a valid GURL, it is persisted to the local password store’s icon_url column by LoginDatabase::BindAddStatement.

When the victim later visits a site matching the shared credential’s signon_realm and the site triggers the Credential Management API (e.g., via navigator.credentials.get()), the browser process attempts to display the credential in the UI (e.g., CredentialsItemView or PasswordCombinedSelectorRowView).

The UI component checks if form->icon_url.is_valid() and immediately instantiates an AccountAvatarFetcher to retrieve the image:

// chrome/browser/ui/views/passwords/credentials_item_view.cc:70
if (form->icon_url.is_valid()) {
  AccountAvatarFetcher* fetcher = new AccountAvatarFetcher(
      form->icon_url, weak_ptr_factory_.GetWeakPtr());
  fetcher->Start(loader_factory, initiator);
}

The fetcher issues an HTTP GET request via the NetworkService. Although cookies are omitted (CredentialsMode::kOmit), the request itself acts as a tracking beacon. The attacker’s server receives the request, revealing the victim’s IP address and the precise timestamp of their visit to the target site.

Note: These are potential steps based on source code analysis, as we do not have a working proof of concept.

Potential Exploit Steps

  1. Attacker Setup: The attacker and victim are in the same Google Family with password sync enabled.
  2. Crafting the Payload: The attacker crafts a PasswordSharingInvitationSpecifics message. They set signon_realm and origin to the target website (e.g., https://target.com/) and avatar_url to their tracking beacon (e.g., https://attacker.com/beacon.png).
  3. Sending: The attacker’s client HPKE-encrypts the payload and sends it via the sync server.
  4. Ingestion: The victim’s browser receives and decrypts the invitation. The credential is saved to the password store because IsValidSharedPasswordForm fails to validate the icon_url.
  5. Tracking Triggered: The victim browses to https://target.com/ and the Credential Management API is invoked by the site.
  6. Beacon Fetched: The browser UI attempts to fetch the attacker’s URL via AccountAvatarFetcher. The attacker logs the request, confirming the victim’s visit.

Suggested Fix

Update IsValidSharedPasswordForm() in components/password_manager/core/browser/sharing/password_receiver_service_impl.cc to explicitly validate the icon_url field. It should verify that the URL is either empty, or a valid, potentially trustworthy URL (e.g., using network::IsUrlPotentiallyTrustworthy). Furthermore, it may be prudent to restrict icon_url origins to match the signon_realm to prevent cross-origin leaks.

Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09


Results 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