diff --git a/components/password_manager/core/browser/password_autofill_manager_unittest.cc b/components/password_manager/core/browser/password_autofill_manager_unittest.cc index e3088f5019322..0d8f2f8a2cea1 100644 --- a/components/password_manager/core/browser/password_autofill_manager_unittest.cc +++ b/components/password_manager/core/browser/password_autofill_manager_unittest.cc @@ -2360,6 +2360,99 @@ TEST_F(PasswordAutofillManagerTest, metrics_util::PasswordDropdownSelectedOption::kTroubleSigningIn, 1); } +// Regression test: PasswordAutofillManager is per-frame, but the AutofillClient +// (and its single popup controller) is per-tab. When a cross-origin iframe's +// PAM_A receives fill data while the main frame's PAM_B has a kPassword popup +// open, PAM_A blindly calls UpdateAutofillSuggestions() on the shared client. +// ChromeAutofillClient::UpdateAutofillSuggestions only checks FillingProduct +// (both are kPassword), so the popup's suggestion list is replaced with PAM_A's +// VICTIM-origin rows while the popup's delegate stays PAM_B. Recovery-flow rows +// embed cleartext PasswordSuggestionDetails; hovering one routes the victim's +// backup password to the ATTACKER frame's renderer via PAM_B's driver. +TEST_F(PasswordAutofillManagerTest, + CrossFrameOnAddPasswordFillDataLeaksRecoveryPayloadToWrongDriver) { + static constexpr char16_t kVictimPassword[] = u"VICTIM_PASSWORD"; + static constexpr char16_t kVictimBackupPassword[] = u"VICTIM_BACKUP_PASSWORD"; + + // Shared per-tab state: one PasswordManagerClient (=> one + // UndoPasswordChangeController) and one AutofillClient (=> one popup). + TestPasswordManagerClient client; + NiceMock autofill_client; + + // PAM_B = attacker.com main frame. Its saved credential happens to share the + // username "alice" with the victim and (precondition) carries a backup, so + // autofill-on-load drives the per-tab controller to kTroubleSigningIn. + fill_data().preferred_login.backup_password_value = u"attacker_backup_unused"; + fill_data().wait_for_username = false; // main-frame autofill on page load + InitializePasswordAutofillManager(&client, &autofill_client); + ASSERT_EQ(client.GetUndoPasswordChangeController()->GetState(test_username_), + PasswordRecoveryState::kTroubleSigningIn); + + // PAM_A = victim.com cross-origin iframe. Own driver, but SAME per-tab + // AutofillClient and PasswordManagerClient. + NiceMock victim_driver; + ON_CALL(victim_driver, CanShowAutofillUi).WillByDefault(Return(true)); + PasswordAutofillManager victim_pam(&victim_driver, &autofill_client, &client); + + autofill::PasswordFormFillData victim_fill_data; + victim_fill_data.url = GURL("https://victim.example/login"); + victim_fill_data.preferred_login.username_value = test_username_; + victim_fill_data.preferred_login.password_value = kVictimPassword; + victim_fill_data.preferred_login.backup_password_value = kVictimBackupPassword; + victim_fill_data.wait_for_username = true; // cross-origin iframe + + // Attacker frame has a kPassword popup open: the shared per-tab AutofillClient + // reports non-empty suggestions. (In production: delegate_ == PAM_B.) + autofill_client.CreateTestSuggestions(); + + // Step 1 — the cross-frame swap. PAM_A observes the (attacker's) popup is + // open and calls UpdateAutofillSuggestions(kPassword), which is the only + // guard ChromeAutofillClient applies. The swapped list contains a + // kTroubleSigningInEntry whose payload embeds VICTIM cleartext secrets. + std::vector swapped; + EXPECT_CALL(autofill_client, + UpdateAutofillSuggestions(_, FillingProduct::kPassword, _, _)) + .WillOnce(DoAll(SaveArg<0>(&swapped), + [&] { autofill_client.set_autofill_suggestions(swapped); })); + victim_pam.OnAddPasswordFillData(victim_fill_data); + testing::Mock::VerifyAndClearExpectations(&autofill_client); + + auto trouble_it = std::ranges::find( + swapped, autofill::SuggestionType::kTroubleSigningInEntry, + &Suggestion::type); + ASSERT_NE(trouble_it, swapped.end()) + << "PAM_A pushed victim suggestions into the shared per-tab popup"; + const auto trouble_payload = + trouble_it->GetPayload(); + EXPECT_EQ(trouble_payload.password, kVictimPassword); + ASSERT_TRUE(trouble_payload.backup_password); + EXPECT_EQ(*trouble_payload.backup_password, kVictimBackupPassword); + + // Step 2 — the popup's delegate is still PAM_B (attacker). User clicks the + // swapped row: PAM_B handles it and emits a kBackupPasswordEntry built from + // the VICTIM payload. + std::vector proactive; + EXPECT_CALL(autofill_client, + UpdateAutofillSuggestions(_, FillingProduct::kPassword, _, _)) + .WillOnce(SaveArg<0>(&proactive)); + password_autofill_manager_->DidAcceptSuggestion(*trouble_it, + SuggestionPosition{.row = 0}); + auto backup_it = std::ranges::find( + proactive, autofill::SuggestionType::kBackupPasswordEntry, + &Suggestion::type); + ASSERT_NE(backup_it, proactive.end()); + + // Step 3 — the cross-origin sink. Hovering the row makes PAM_B (the wrong + // delegate) ship the VICTIM's cleartext backup password to the ATTACKER + // frame's renderer via its own driver. The victim driver is never called. + EXPECT_CALL(victim_driver, PreviewSuggestion).Times(0); + EXPECT_CALL(*client.mock_driver(), + PreviewSuggestion(Eq(test_username_), + Eq(std::u16string(kVictimBackupPassword)))) + .Times(1); + password_autofill_manager_->DidSelectSuggestion(*backup_it); +} + TEST_F(PasswordAutofillManagerTest, PasswordRecoveryFlow_OnAddPasswordFillDataWithBackupPassword) { TestPasswordManagerClient client;