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 34277569cba28..47c1dc4a0434c 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 @@ -1790,6 +1790,166 @@ TEST_P(ActorLoginCredentialFillerTest, RequestsReauthBeforeFillingAllFields) { LoginStatusResult::kSuccessUsernameAndPasswordFilled); } +// SECURITY: TOCTOU on stale `origin_` across the device-reauth modal. +// +// ActorLoginDelegateImpl captures the primary-main-frame origin once and stores +// it as `origin_` in this class. If the tab is navigated to a same-eTLD+1 +// sibling origin while the OS biometric/PIN modal is showing, the post-reauth +// FetchEligibleForms() still queries with the *stale* `origin_`. With +// kActorLoginSameSiteIframeSupport (default-enabled), +// IsValidFrameAndOriginToFill() accepts the new main frame because +// SameDomainOrHost(evil.example.com, login.example.com) == true and +// IsInPrimaryMainFrame() == true. The PasswordFormUniqueKey guard in +// FillAllEligibleFields() also passes because PSL matches retain the original +// signon_realm/url/username_element/username_value/password_element. The net +// result is that the cleartext password approved for login.example.com is +// delivered to evil.example.com's renderer. +// +// This test currently *passes*, which demonstrates the bug: a correctly +// behaving filler MUST NOT call FillField() on a driver whose committed origin +// differs from the origin the user approved in the reauth prompt. +TEST_P(ActorLoginCredentialFillerTest, + TOCTOU_FillsCrossOriginMainFrameAfterNavigationDuringReauth) { + // Default-enabled features that gate the vulnerable branches; pin them on so + // the test does not silently rot if defaults flip. + base::test::ScopedFeatureList feature_list; + feature_list.InitWithFeatures( + /*enabled_features=*/ + {password_manager::features::kActorLoginSameSiteIframeSupport, + password_manager::features::kActorLoginFieldVisibilityCheck, + password_manager::features::kActorLoginReauthTaskRefocus}, + /*disabled_features=*/{}); + + // The origin shown to the user in the OS reauth prompt. + const url::Origin victim_origin = + url::Origin::Create(GURL("https://login.example.com")); + // Same eTLD+1 sibling that the attacker navigates the tab to while the + // reauth modal is up. + const url::Origin evil_origin = + url::Origin::Create(GURL("https://evil.example.com")); + main_frame_origin_ = victim_origin; + + // The credential the Actor selected on login.example.com. + const Credential credential = CreateTestCredential( + kTestUsername, victim_origin.GetURL(), victim_origin); + + // Saved password for login.example.com. This is what the user approves. + PasswordForm saved_credential = CreateSavedPasswordForm( + victim_origin.GetURL(), kTestUsername, kTestPassword); + + // Drivers/fetchers for both phases. Declared up front so they outlive the + // form managers and the filler (the evil objects are populated in Phase 2). + testing::NiceMock victim_driver; + testing::NiceMock evil_driver; + FakeFormFetcher victim_fetcher; + FakeFormFetcher evil_fetcher; + + // --- Phase 1: pre-reauth state. login.example.com is the main frame. --- + ON_CALL(victim_driver, IsNestedWithinFencedFrame) + .WillByDefault(Return(false)); + ON_CALL(victim_driver, CheckViewAreaVisible) + .WillByDefault(WithArg<1>(&PostResponse)); + victim_fetcher.SetBestMatches({saved_credential}); + std::vector> form_managers; + form_managers.push_back(CreateFormManagerWithParsedForm( + victim_origin, CreateSigninFormData(victim_origin.GetURL()), + victim_driver, victim_fetcher)); + // The genuine login page is the primary main frame. + ON_CALL(victim_driver, IsInPrimaryMainFrame).WillByDefault(Return(true)); + + // Dynamically reflect the *current* form cache contents (we mutate + // `form_managers` between phases to simulate DidNavigateMainFrame clearing + // the cache and the new renderer reporting a fresh form). + ON_CALL(mock_form_cache_, GetFormManagers) + .WillByDefault(Invoke( + [&]() -> base::span> { + return base::span(form_managers); + })); + + // Reauth-before-filling is required and the task is in focus. + ON_CALL(mock_is_task_in_focus_, Run).WillByDefault(Return(true)); + MockDeviceAuthenticator* weak_device_authenticator = + SetUpDeviceAuthenticatorToRequireReauth(mock_client_); + + // Capture the reauth-completion callback instead of running it inline. This + // models the multi-second OS biometric/PIN modal. + DeviceAuthenticator::AuthenticateCallback captured_reauth_cb; + EXPECT_CALL(*weak_device_authenticator, AuthenticateWithMessage) + .WillOnce([&](const std::u16string&, + DeviceAuthenticator::AuthenticateCallback cb) { + captured_reauth_cb = std::move(cb); + }); + + base::test::TestFuture future; + ActorLoginCredentialFiller filler( + victim_origin, credential, should_store_permission(), &mock_client_, + mqls_logger(), base::TimeTicks::Now(), mock_is_task_in_focus_.Get(), + future.GetCallback()); + + // The victim driver must never receive the password — by the time the user + // approves, that frame is gone. + EXPECT_CALL(victim_driver, FillField).Times(0); + + filler.AttemptLogin(&mock_password_manager_); + // Drain the async visibility checks so we reach the reauth modal. + task_environment_.RunUntilIdle(); + ASSERT_TRUE(captured_reauth_cb) << "Reauth modal was never shown"; + ASSERT_FALSE(future.IsReady()); + + // --- Phase 2: opener navigates the tab to evil.example.com while the OS + // reauth modal is showing. PasswordManager::DidNavigateMainFrame clears the + // form cache; the new renderer reports a login form; a new + // PasswordFormManager fetches PSL matches for evil.example.com which include + // the login.example.com credential with its original unique-key fields. + ON_CALL(evil_driver, IsNestedWithinFencedFrame).WillByDefault(Return(false)); + ON_CALL(evil_driver, CheckViewAreaVisible) + .WillByDefault(WithArg<1>(&PostResponse)); + PasswordForm psl_match = saved_credential; + psl_match.match_type = PasswordForm::MatchType::kPSL; + evil_fetcher.SetBestMatches({psl_match}); + std::unique_ptr evil_manager = + CreateFormManagerWithParsedForm( + evil_origin, CreateSigninFormData(evil_origin.GetURL()), evil_driver, + evil_fetcher); + // After navigation, evil.example.com *is* the primary main frame. + ON_CALL(evil_driver, IsInPrimaryMainFrame).WillByDefault(Return(true)); + const PasswordForm* evil_parsed = evil_manager->GetParsedObservedForm(); + ASSERT_TRUE(evil_parsed); + + form_managers.clear(); + form_managers.push_back(std::move(evil_manager)); + + // *** THE BUG ***: the cleartext password approved for login.example.com is + // sent to evil.example.com's renderer. The capture page can read it via + // document.forms[0].p.value. + std::u16string leaked_value; + EXPECT_CALL(evil_driver, + FillField(evil_parsed->password_element_renderer_id, + Eq(kTestPassword), + autofill::FieldPropertiesFlags::kAutofilledActorLogin, + _)) + .WillOnce(DoAll(testing::SaveArg<1>(&leaked_value), + RunOnceCallback<3>(true))); + EXPECT_CALL(evil_driver, + FillField(evil_parsed->username_element_renderer_id, + Eq(kTestUsername), _, _)) + .WillOnce(RunOnceCallback<3>(true)); + + // --- Phase 3: user approves the OS prompt (which still names + // login.example.com). + std::move(captured_reauth_cb).Run(/*authenticated=*/true); + task_environment_.RunUntilIdle(); + + ASSERT_TRUE(future.IsReady()); + ASSERT_TRUE(future.Get().has_value()); + EXPECT_EQ(future.Get().value(), + LoginStatusResult::kSuccessUsernameAndPasswordFilled); + EXPECT_EQ(leaked_value, kTestPassword) + << "Password for " << victim_origin << " was disclosed to " + << evil_origin; + EXPECT_FALSE(evil_origin.IsSameOriginWith(victim_origin)); +} + TEST_P(ActorLoginCredentialFillerTest, FillAllFields_OnlyUsernamesVisible_AsyncVisibilityCheck) { base::test::ScopedFeatureList feature_list;