diff --git a/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc b/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc index d93b342cee8ef..a6e1f81d58dac 100644 --- a/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc +++ b/components/password_manager/core/browser/password_manual_fallback_flow_unittest.cc @@ -1065,6 +1065,138 @@ INSTANTIATE_TEST_SUITE_P(PasswordManualFallbackFlowTest, Values(SuggestionType::kPasswordEntry, SuggestionType::kFillPassword)); +// ===================================================================== +// SECURITY: Regression POC — grouped (weakly-affiliated) credentials +// bypass ShowCrossDomainConfirmationPopup in the manual-fallback flow. +// +// PasswordManualFallbackFlow unconditionally calls +// `set_filter_grouped_credentials(false)` on its FormFetcher, so +// `MatchType::kGrouped` credentials from a *foreign* origin are returned +// in `GetBestMatches()`. `GetManualFallbackSuggestions()` then hard-codes +// `IsCrossDomain(false)` for every entry in the "Suggested" section. +// `EnsureCrossDomainPasswordUsageGetsConsent()` keys solely on that flag, +// so accepting the suggestion fills the foreign-domain plaintext password +// into the renderer with NO cross-domain interstitial. +// +// The parallel non-fallback path (`PasswordAutofillManager:: +// DidAcceptSuggestion`) re-derives `is_grouped_affiliation` from +// `PasswordAndMetadata` and DOES show +// `ShowCrossDomainConfirmationPopup(..., show_warning_text=true, ...)` +// for the identical match class. +// +// This test PASSES while the bug is present (i.e. it asserts the +// vulnerable behaviour). When fixed, the assertions on +// `is_cross_domain == false` and `ShowCrossDomainConfirmationPopup` +// `.Times(0)` will fail. +// ===================================================================== +TEST_F(PasswordManualFallbackFlowTest, + GroupedCredentialBypassesCrossDomainConfirmation_SecurityPOC) { + // ----------------------------------------------------------------- + // STAGE 1: Set up a foreign-domain credential that the affiliation + // service marks as a *grouped* (weakly-affiliated) sibling of the + // attacker-controlled origin. + // ----------------------------------------------------------------- + const std::string kAttackerUrl = kUrlWithNoExactMatches; // www.foo.com + const std::string kVictimRealm = "https://grouped-victim.test/"; + const std::u16string kVictimPassword = u"victim_secret_password"; + + profile_password_store().AddLogin( + CreateEntry("victim_user", base::UTF16ToUTF8(kVictimPassword), + GURL(kVictimRealm), PasswordForm::MatchType::kExact)); + + // The PasswordManualFallbackFlow ctor builds a FormFetcherImpl whose + // PasswordFormDigest is derived from `GetLastCommittedURL()` — i.e. the + // attacker origin. + PasswordFormDigest attacker_digest(PasswordForm::Scheme::kHtml, + /*signon_realm=*/kAttackerUrl, + GURL(kAttackerUrl)); + // FacetURIs canonically have no trailing slash; the request handler + // re-appends "/" when deriving the realm to query. + affiliated_match_helper().ExpectCallToGetAffiliatedAndGrouped( + attacker_digest, + /*affiliated_realms=*/{}, + /*grouped_realms=*/{"https://grouped-victim.test"}, + /*repeatedly=*/true); + + InitializeFlow(kAttackerUrl); + ProcessPasswordStoreUpdates(); + + // ----------------------------------------------------------------- + // STAGE 2: Run the manual-fallback flow on a parsed password form + // and capture the generated suggestions. + // ----------------------------------------------------------------- + PasswordForm parsed_form; + parsed_form.username_element_renderer_id = MakeFieldRendererId(); + parsed_form.password_element_renderer_id = MakeFieldRendererId(); + EXPECT_CALL(password_form_cache(), + GetPasswordForm(_, parsed_form.password_element_renderer_id)) + .WillRepeatedly(Return(&parsed_form)); + + std::vector shown_suggestions; + EXPECT_CALL(autofill_client(), ShowAutofillSuggestions(_, _)) + .WillOnce([&](const AutofillClient::PopupOpenArgs& args, + base::WeakPtr) { + shown_suggestions = args.suggestions; + return AutofillClient::SuggestionUiSessionId(); + }); + flow().RunFlow(parsed_form.password_element_renderer_id, gfx::RectF{}, + TextDirection::LEFT_TO_RIGHT); + + // ----------------------------------------------------------------- + // STAGE 3: Locate the foreign-domain grouped credential among the + // generated suggestions and verify the BUG: it carries the foreign + // domain's plaintext password yet `is_cross_domain == false`. + // ----------------------------------------------------------------- + const Suggestion* grouped_suggestion = nullptr; + for (const Suggestion& s : shown_suggestions) { + if (s.type != SuggestionType::kPasswordEntry) { + continue; + } + const auto& payload = s.GetPayload(); + if (payload.signon_realm == kVictimRealm) { + grouped_suggestion = &s; + break; // first match = "Suggested"-section entry + } + } + ASSERT_NE(grouped_suggestion, nullptr) + << "Grouped foreign-domain credential was not surfaced in the " + "manual-fallback popup; FormFetcher should have included it because " + "set_filter_grouped_credentials(false) is set unconditionally."; + + const auto& payload = + grouped_suggestion->GetPayload(); + EXPECT_EQ(payload.password, kVictimPassword) + << "Foreign-domain plaintext password is embedded in the suggestion."; + + // *** VULNERABILITY ASSERTION *** + // The credential belongs to a different origin (grouped-victim.test) than + // the page on which the popup is shown (www.foo.com), yet the generator + // hard-codes IsCrossDomain(false). The regular autofill path treats the + // identical kGrouped match as requiring the cross-domain interstitial. + EXPECT_FALSE(payload.is_cross_domain) + << "BUG NOT PRESENT: grouped credential was correctly marked " + "is_cross_domain=true."; + + // ----------------------------------------------------------------- + // STAGE 4: Simulate the single user click on the suggestion. Because + // `payload.is_cross_domain` is false, the confirmation popup is NOT + // shown and the foreign-domain password is sent straight to the + // (potentially compromised) renderer. + // ----------------------------------------------------------------- + EXPECT_CALL(password_manager_client(), ShowCrossDomainConfirmationPopup) + .Times(0); + EXPECT_CALL(password_manager_client(), IsReauthBeforeFillingRequired) + .WillRepeatedly(Return(false)); + EXPECT_CALL(driver(), + FillSuggestionById(parsed_form.username_element_renderer_id, + parsed_form.password_element_renderer_id, _, + kVictimPassword, _)) + .Times(1); + + ShowAndAcceptSuggestion(*grouped_suggestion, + AutofillSuggestionDelegate::SuggestionMetadata{}); +} + #endif // BUILDFLAG(IS_WIN) || BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_MAC) || // BUILDFLAG(IS_CHROMEOS)