diff --git a/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler_unittest.cc b/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler_unittest.cc
index 8188b1c12baf7..ab864a3035737 100644
--- a/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler_unittest.cc
+++ b/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler_unittest.cc
@@ -1170,6 +1170,135 @@ TEST_P(ActorLoginCredentialFillerTest,
LoginStatusResult::kSuccessUsernameAndPasswordFilled);
}
+// POC: Password leaks to a sibling same-site iframe via PSL-match unique-key
+// collision.
+//
+// Scenario:
+// - Main frame at https://example.com (attacker-controlled top-level page).
+// - Iframe 1 at https://login.example.com (legitimate SSO widget). Its
+// FormFetcher returns the saved credential as an EXACT match.
+// - Iframe 2 at https://evil.example.com (attacker-controlled). In
+// production, its FormFetcherImpl queries the password store for
+// evil.example.com and receives the login.example.com record as a PSL
+// match. The PSL-matched record retains the ORIGINAL store fields
+// (signon_realm, url, username_element, username_value, password_element)
+// so its unique key is IDENTICAL to iframe 1's exact match.
+//
+// Because GetSigninFormManager() picks iframe 1 (no main-frame form ->
+// should_skip_iframes=false), and DoesStoredCredentialBelongToManager()
+// compares only AreStoredCredentialUniqueKeysEqual (which ignores match_type),
+// the check passes for BOTH managers and FillField() is dispatched to BOTH
+// iframes. The attacker iframe then reads its own .value
+// and exfiltrates the secret.
+TEST_P(ActorLoginCredentialFillerTest,
+ PslMatchLeaksPasswordToSiblingSameSiteIframe) {
+ const std::u16string kSecretPassword = u"hunter2-SECRET";
+ const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+ main_frame_origin_ = origin;
+ const url::Origin legit_origin =
+ url::Origin::Create(GURL("https://login.example.com"));
+ const url::Origin attacker_origin =
+ url::Origin::Create(GURL("https://evil.example.com"));
+
+ // The user-approved Credential was sourced from login.example.com (the
+ // user sees "Use login.example.com credential on example.com" in the UI).
+ const Credential credential =
+ CreateTestCredential(kTestUsername, legit_origin.GetURL(), origin);
+
+ const FormData legit_form_data = CreateSigninFormData(legit_origin.GetURL());
+ const FormData attacker_form_data =
+ CreateSigninFormData(attacker_origin.GetURL());
+
+ // Iframe 1's FormFetcher: exact match for login.example.com.
+ FakeFormFetcher legit_fetcher;
+ PasswordForm exact_match =
+ CreateSavedPasswordForm(legit_origin.GetURL(), kTestUsername,
+ kSecretPassword);
+ exact_match.match_type = PasswordForm::MatchType::kExact;
+ legit_fetcher.SetBestMatches({exact_match});
+
+ // Iframe 2's FormFetcher: this models what the REAL FormFetcherImpl returns
+ // in production for evil.example.com. The store record for
+ // login.example.com is returned as a PSL match with all unique-key fields
+ // (signon_realm/url/username_element/username_value/password_element) left
+ // UNCHANGED -- only match_type is set to kPSL.
+ FakeFormFetcher attacker_fetcher;
+ PasswordForm psl_match =
+ CreateSavedPasswordForm(legit_origin.GetURL(), kTestUsername,
+ kSecretPassword);
+ psl_match.match_type = PasswordForm::MatchType::kPSL;
+ attacker_fetcher.SetBestMatches({psl_match});
+
+ // Two sibling iframe form managers, both direct children of the main frame.
+ std::vector> form_managers;
+ testing::NiceMock legit_driver;
+ testing::NiceMock attacker_driver;
+ form_managers.push_back(CreateFormManagerWithParsedForm(
+ legit_origin, legit_form_data, legit_driver, legit_fetcher));
+ form_managers.push_back(CreateFormManagerWithParsedForm(
+ attacker_origin, attacker_form_data, attacker_driver, attacker_fetcher));
+
+ const PasswordForm* legit_parsed = form_managers[0]->GetParsedObservedForm();
+ const PasswordForm* attacker_parsed =
+ form_managers[1]->GetParsedObservedForm();
+
+ base::test::TestFuture future;
+ ActorLoginCredentialFiller filler(
+ origin, credential, should_store_permission(), &mock_client_,
+ mqls_logger(), base::TimeTicks::Now(), mock_is_task_in_focus_.Get(),
+ future.GetCallback());
+
+ ON_CALL(mock_form_cache_, GetFormManagers)
+ .WillByDefault(Return(base::span(form_managers)));
+
+ // Both iframes are direct, non-fenced children of the primary main frame
+ // with visible login forms (the attacker iframe honestly renders one).
+ for (auto* d : {&legit_driver, &attacker_driver}) {
+ ON_CALL(*d, IsNestedWithinFencedFrame).WillByDefault(Return(false));
+ ON_CALL(*d, IsDirectChildOfPrimaryMainFrame).WillByDefault(Return(true));
+ ON_CALL(*d, CheckViewAreaVisible)
+ .WillByDefault(WithArg<1>(&PostResponse));
+ }
+
+ // Expected (intended) behaviour: only the legitimate iframe is filled.
+ EXPECT_CALL(legit_driver,
+ FillField(legit_parsed->username_element_renderer_id,
+ Eq(kTestUsername), _, _))
+ .WillOnce(RunOnceCallback<3>(true));
+ EXPECT_CALL(legit_driver,
+ FillField(legit_parsed->password_element_renderer_id,
+ Eq(kSecretPassword), _, _))
+ .WillOnce(RunOnceCallback<3>(true));
+
+ // *** THE LEAK ***
+ // The attacker iframe's driver receives FillField() with the secret
+ // password. In production this is a Mojo IPC to the attacker's renderer,
+ // which then reads input.value and exfiltrates it. A correct
+ // implementation must NEVER call FillField on the attacker driver, so this
+ // expectation existing at all demonstrates the vulnerability.
+ std::u16string leaked_password;
+ EXPECT_CALL(attacker_driver,
+ FillField(attacker_parsed->username_element_renderer_id,
+ Eq(kTestUsername), _, _))
+ .WillOnce(RunOnceCallback<3>(true));
+ EXPECT_CALL(attacker_driver,
+ FillField(attacker_parsed->password_element_renderer_id, _, _, _))
+ .WillOnce(DoAll(testing::SaveArg<1>(&leaked_password),
+ RunOnceCallback<3>(true)));
+
+ filler.AttemptLogin(&mock_password_manager_);
+ const LoginStatusResultOrError& result = future.Get();
+ ASSERT_TRUE(result.has_value());
+ EXPECT_EQ(result.value(),
+ LoginStatusResult::kSuccessUsernameAndPasswordFilled);
+
+ // Proof of disclosure: the attacker iframe received the cleartext password.
+ EXPECT_EQ(kSecretPassword, leaked_password)
+ << "Password was sent via FillField IPC to attacker-controlled iframe "
+ "at "
+ << attacker_origin.Serialize();
+}
+
TEST_P(ActorLoginCredentialFillerTest,
FillUsernameAndPasswordInAllEligibleFieldsPreferMainframe) {
const url::Origin origin = url::Origin::Create(GURL("https://example.com"));