CVE-2026-87590
Overview
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
chrome/browser/sync/test/integration/password_sharing_invitation_helper.cccomponents/password_manager/core/browser/sharing/BUILD.gncomponents/password_manager/core/browser/sharing/password_receiver_service_impl.cccomponents/password_manager/core/browser/sharing/password_receiver_service_impl_unittest.cc
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/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/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",
]
}
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/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
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.