CVE-2026-87590
Overview
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_datathat is copied verbatim intoPasswordForm::icon_url. - Same-origin
- a scheme/host/port match;
url::IsSameOriginWithcompares twoGURLs 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.
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
- Craft a malicious invitation
An attacker constructs a password sharing invitation whose
password_group_element_data.avatar_urlpoints at an attacker-controlled cross-origin endpoint (e.g.https://attacker-beacon.test/track.png) or a non-web scheme. - Deliver via Sync
The invitation reaches the victim’s client through the password sharing sync channel and enters
ProcessIncomingSharingInvitation. - 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 storedPasswordForm. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
PasswordReceiverServiceImplIconUrlTestcomponents/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc |
modified | |
TEST_Pcomponents/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.cccomponents/password_manager/core/browser/sharing/password_receiver_service_impl.ccchrome/browser/sync/test/integration/password_sharing_invitation_helper.cccomponents/password_manager/core/browser/sharing/BUILD.gn
Audit Directions
- Sender-controlled URL fieldsAudit every field in Sync-delivered or shared data (avatar/image/icon/profile URLs, deep links) that is copied into a
GURLand later fetched or navigated, and confirm scheme and origin are validated before use. - Validate-at-both-gatesWhere 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 UIReview 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.
Patch
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",
]
}
Regression Test / PoC
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
Original Bug Report
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.ccchrome/browser/ui/views/passwords/credentials_item_view.ccchrome/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
- Attacker Setup: The attacker and victim are in the same Google Family with password sync enabled.
- Crafting the Payload: The attacker crafts a
PasswordSharingInvitationSpecificsmessage. They setsignon_realmandoriginto the target website (e.g.,https://target.com/) andavatar_urlto their tracking beacon (e.g.,https://attacker.com/beacon.png). - Sending: The attacker’s client HPKE-encrypts the payload and sends it via the sync server.
- Ingestion: The victim’s browser receives and decrypts the invitation. The credential is saved to the password store because
IsValidSharedPasswordFormfails to validate theicon_url. - Tracking Triggered: The victim browses to
https://target.com/and the Credential Management API is invoked by the site. - 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.