diff --git a/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc b/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc index d31af6b147238..146b972531b40 100644 --- a/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc +++ b/components/autofill/core/browser/foundations/browser_autofill_manager_unittest.cc @@ -4153,6 +4153,131 @@ TEST_F(BrowserAutofillManagerTest, #endif // BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC) || BUILDFLAG(IS_LINUX) || // BUILDFLAG(IS_CHROMEOS) +// SECURITY: Control case for the refill-bypasses-mandatory-reauth POC. +// When a credit-card form *contains* a cc-number field at suggestion-accept +// time, BrowserAutofillManager::FillOrPreviewCreditCardForm() correctly +// detects via ShouldFetchCreditCard() that the PAN will be filled and routes +// the local card through CreditCardAccessManager::FetchCreditCard(), which is +// the only entry point to the Payments Mandatory Reauth device-auth prompt. +TEST_F(BrowserAutofillManagerTest, + MandatoryReauthBypass_Control_FetchCreditCardCalledWhenNumberPresent) { + CreditCard local_card = test::GetCreditCard(); // Local card, full PAN. + ASSERT_EQ(local_card.record_type(), CreditCard::RecordType::kLocalCard); + + FormData form = test::GetFormData( + {.fields = { + {.role = CREDIT_CARD_NAME_FULL, .autocomplete_attribute = "cc-name"}, + {.role = CREDIT_CARD_NUMBER, .autocomplete_attribute = "cc-number"}, + {.role = CREDIT_CARD_EXP_MONTH, + .autocomplete_attribute = "cc-exp-month"}, + {.role = CREDIT_CARD_EXP_4_DIGIT_YEAR, + .autocomplete_attribute = "cc-exp-year"}}}); + autofill_client().set_last_committed_primary_main_frame_url(form.url()); + FormsSeen({form}); + + // Because the form has a fillable cc-number field, the kPopup fill path + // MUST go through FetchCreditCard() — and therefore through the mandatory + // reauth check at CreditCardAccessManager::FetchLocalCard(). + EXPECT_CALL(cc_access_manager(), FetchCreditCard) + .Times(1) + .WillOnce(base::test::RunOnceCallback<1>(local_card)); + EXPECT_CALL(autofill_driver(), ApplyFormAction).Times(AtLeast(1)); + + autofill_manager().FillOrPreviewForm( + mojom::ActionPersistence::kFill, form, form.fields()[0].global_id(), + &local_card, AutofillTriggerSource::kPopup); +} + +// SECURITY POC: A form that contains only cc-name + cc-exp at suggestion-accept +// time causes ShouldFetchCreditCard() to return false, so the *full* local +// CreditCard object (cleartext PAN) is passed directly to FormFiller and +// cached by value in RefillContext. If the page then appends a cc-number input +// and the form is re-parsed within kLimitBeforeAutomaticRefill, TriggerRefill() +// fills the new cc-number field with the full PAN — and *never* calls +// FetchCreditCard(), so the Payments Mandatory Reauth device-auth prompt that +// the user explicitly enabled is silently bypassed. +TEST_F(BrowserAutofillManagerTest, + MandatoryReauthBypass_RefillDisclosesPANWithoutFetchCreditCard) { + // Mandatory Reauth is a user-controlled security setting; enable it to make + // the security delta explicit (the bypass below also occurs with the default + // pref since the gate is structural, not pref-based). + static_cast( + personal_data().payments_data_manager()) + .SetPaymentMethodsMandatoryReauthEnabled(true); + + CreditCard local_card = test::GetCreditCard(); // Local card, full PAN. + ASSERT_EQ(local_card.record_type(), CreditCard::RecordType::kLocalCard); + ASSERT_EQ(local_card.GetRawInfo(CREDIT_CARD_NUMBER), u"4111111111111111"); + + // 1) Attacker page initially shows only cc-name + cc-exp. NO cc-number. + FormData form = test::GetFormData( + {.fields = { + {.role = CREDIT_CARD_NAME_FULL, .autocomplete_attribute = "cc-name"}, + {.role = CREDIT_CARD_EXP_MONTH, + .autocomplete_attribute = "cc-exp-month"}, + {.role = CREDIT_CARD_EXP_4_DIGIT_YEAR, + .autocomplete_attribute = "cc-exp-year"}}}); + autofill_client().set_last_committed_primary_main_frame_url(form.url()); + FormsSeen({form}); + + // 2) Across the entire test — initial fill AND refill — FetchCreditCard() + // must NEVER be reached. This is the only path to FetchLocalCard()'s + // mandatory-reauth device-auth prompt; Times(0) proves it is bypassed. + EXPECT_CALL(cc_access_manager(), FetchCreditCard).Times(0); + + // Capture each ApplyFormAction (the data sent to the renderer). + std::vector> sent_batches; + EXPECT_CALL(autofill_driver(), ApplyFormAction) + .WillRepeatedly( + [&](mojom::FormActionType, mojom::ActionPersistence, + base::span data, const FillId&, bool, + const url::Origin&, + const absl::flat_hash_map&, + const Section&) { + sent_batches.emplace_back(data.begin(), data.end()); + return base::MakeFlatSet(data, {}, + &FormFieldData::global_id); + }); + + // 3) Victim accepts the card suggestion via the popup on cc-name. + // Because WillFillCreditCardNumberOrCvc()==false, no reauth. + autofill_manager().FillOrPreviewForm( + mojom::ActionPersistence::kFill, form, form.fields()[0].global_id(), + &local_card, AutofillTriggerSource::kPopup); + ASSERT_EQ(sent_batches.size(), 1u); + // The initial fill must NOT have leaked the PAN (no cc-number field yet). + for (const FormFieldData& f : sent_batches[0]) { + EXPECT_NE(f.value(), u"4111111111111111"); + } + + // 4) Attacker page synchronously appends . + test_api(form).Append(test::CreateTestFormField( + "", "stolen", "", FormControlType::kInputText, "cc-number")); + FieldGlobalId stolen_id = form.fields().back().global_id(); + + // 5) Renderer sends FormsSeen with the mutated form. OnFormParsed → + // MaybeScheduleAutomaticRefill(kFormChanged) → TriggerRefill (TestFormFiller + // runs the refill synchronously). + FormsSeen({form}); + + // 6) PROOF OF BYPASS: a second ApplyFormAction batch was sent to the + // renderer containing the appended cc-number field with the *full + // cleartext PAN*, and FetchCreditCard() (mandatory reauth gate) was + // never invoked at any point. + ASSERT_GE(sent_batches.size(), 2u) << "Refill did not fire"; + bool pan_disclosed = false; + for (size_t b = 1; b < sent_batches.size(); ++b) { + for (const FormFieldData& f : sent_batches[b]) { + if (f.global_id() == stolen_id) { + EXPECT_EQ(f.value(), u"4111111111111111"); + pan_disclosed = (f.value() == u"4111111111111111"); + } + } + } + EXPECT_TRUE(pan_disclosed) + << "Appended cc-number field was not filled with the full PAN on refill"; +} + // Test that the importing logic is called on form submit. TEST_F(BrowserAutofillManagerTest, FormSubmitted_FormDataImporter) { TestAddressDataManager& adm = personal_data().test_address_data_manager();