Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Passwords
DescriptionInappropriate implementation in Passwords
ComponentPasswords
Bug ClassLogic Error
Tracker517762104
Fix commit8e1f076a8956 (chromium/src) +504/-12
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Changed Functions

FunctionChangeNotes
if
components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
modified
for
components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
modified

Files Changed

  • components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
  • components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.h
  • components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler_unittest.cc
From 8e1f076a89560e6647c79c0015af4837a897691c Mon Sep 17 00:00:00 2001
From: Ioana Pandele <ioanap@chromium.org>
Date: Mon, 01 Jun 2026 07:50:01 -0700
Subject: [PATCH] [ActorLogin] Fill strong matches only in the corresponding frames

If there is an iframe for which the credential is an exact or affiliated
match, only fill that iframe or other sibling iframes for which the
credential is a strong match.

Fixed: 517762104
Change-Id: Ie1d6ea01428e830b6fa530902f7997a6c4301f57
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7889815
Reviewed-by: Oleksandr Tara <otara@google.com>
Commit-Queue: Ioana Treib <ioanap@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1639384}
---

diff --git a/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc b/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
index 3d2ef1fd..4b8a194 100644
--- a/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
+++ b/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
@@ -27,6 +27,7 @@
 #include "components/password_manager/core/browser/password_manager_driver.h"
 #include "components/password_manager/core/browser/password_manager_interface.h"
 #include "components/password_manager/core/browser/password_manager_metrics_util.h"
+#include "components/password_manager/core/browser/password_manager_util.h"
 #include "components/password_manager/core/browser/password_store/password_form_converters.h"
 #include "components/password_manager/core/browser/password_store/stored_credential.h"
 #include "components/strings/grit/components_strings.h"
@@ -236,8 +237,10 @@
   std::unique_ptr<BrowserSavePasswordProgressLogger> logger =
       GetLogger(client_);
 
-  password_manager::PasswordFormManager* signin_form_manager =
-      ActorLoginFormFinder::GetSigninFormManager(eligible_managers);
+  password_manager::PasswordFormManager* signin_form_manager = nullptr;
+  const password_manager::StoredCredential* stored_credential = nullptr;
+  std::tie(signin_form_manager, stored_credential) =
+      FindReferenceFormAndCredential(eligible_managers);
 
   if (!signin_form_manager) {
     LogStatus(logger.get(), Logger::STRING_ACTOR_LOGIN_NO_SIGNIN_FORM);
@@ -246,9 +249,6 @@
     return;
   }
 
-  const password_manager::StoredCredential* stored_credential =
-      GetMatchingStoredCredential(*signin_form_manager);
-
   if (!stored_credential) {
     LogStatus(logger.get(), Logger::STRING_ACTOR_LOGIN_INVALID_CREDENTIAL);
     BuildAttemptLoginOutcome(AttemptLoginOutcomeMqls::kInvalidCredential);
@@ -263,6 +263,47 @@
       password_manager::CloneStoredCredential(*stored_credential));
 }
 
+std::pair<password_manager::PasswordFormManager*,
+          const password_manager::StoredCredential*>
+ActorLoginCredentialFiller::FindReferenceFormAndCredential(
+    const std::vector<password_manager::PasswordFormManager*>&
+        eligible_managers) {
+  // Check if there is a primary main frame form.
+  password_manager::PasswordFormManager* preferred_manager =
+      ActorLoginFormFinder::GetSigninFormManager(eligible_managers);
+  if (preferred_manager &&
+      preferred_manager->GetDriver()->IsInPrimaryMainFrame()) {
+    return {preferred_manager, GetMatchingStoredCredential(*preferred_manager)};
+  }
+
+  // Try to find a manager where the credential is an exact match.
+  for (auto* manager : eligible_managers) {
+    const password_manager::StoredCredential* match =
+        GetMatchingStoredCredential(*manager);
+    if (match && password_manager_util::GetMatchType(*match) ==
+                     password_manager_util::GetLoginMatchType::kExact) {
+      return {manager, match};
+    }
+  }
+
+  // Try to find a manager where the credential is an affiliated match.
+  for (auto* manager : eligible_managers) {
+    const password_manager::StoredCredential* match =
+        GetMatchingStoredCredential(*manager);
+    if (match && password_manager_util::GetMatchType(*match) ==
+                     password_manager_util::GetLoginMatchType::kAffiliated) {
+      return {manager, match};
+    }
+  }
+
+  // Fall back to the default preferred manager.
+  if (preferred_manager) {
+    return {preferred_manager, GetMatchingStoredCredential(*preferred_manager)};
+  }
+
+  return {nullptr, nullptr};
+}
+
 void ActorLoginCredentialFiller::MaybeReauthAndFillAllEligibleFields(
     std::vector<password_manager::PasswordFormManager*> eligible_managers,
     password_manager::StoredCredential stored_credential) {
@@ -317,8 +358,8 @@
     // Don't consider weakly affiliated credentials (grouped) because they are
     // not provided in the "Get" step and thus don't actually match the
     // credential that was selected for filling.
-    if (stored_credential_form.match_type.value() ==
-        password_manager::PasswordForm::MatchType::kGrouped) {
+    if (password_manager_util::GetMatchType(stored_credential_form) ==
+        password_manager_util::GetLoginMatchType::kGrouped) {
       continue;
     }
     if (stored_credential_form.username_value == credential_.username &&
@@ -333,11 +374,34 @@
 bool ActorLoginCredentialFiller::DoesStoredCredentialBelongToManager(
     const password_manager::PasswordFormManager* manager,
     const password_manager::StoredCredential& stored_credential) {
+  // If the stored credential matched the reference form exactly or via
+  // affiliation, we restrict filling to only those managers where the
+  // credential is also of the same match type. This prevents filling an
+  // exact credential into affiliated sibling same-site iframes.
+  // Vice-versa isn't possible anyway, because we already check to see if there
+  // are any exact matches. If we landed on an affiliated match, there were no
+  // exact matches.
+  // The logic also prevents filling any strong matches into weak (PSL-matched)
+  // sibling iframes.
+  password_manager_util::GetLoginMatchType ref_match_type =
+      password_manager_util::GetMatchType(stored_credential);
   return std::ranges::any_of(
       manager->GetBestMatches().begin(), manager->GetBestMatches().end(),
-      [&stored_credential](const password_manager::StoredCredential& best_match) {
-        return password_manager::AreStoredCredentialUniqueKeysEqual(
-            stored_credential, best_match);
+      [&stored_credential,
+       ref_match_type](const password_manager::StoredCredential& best_match) {
+        if (!password_manager::AreStoredCredentialUniqueKeysEqual(
+                stored_credential, best_match)) {
+          return false;
+        }
+        password_manager_util::GetLoginMatchType best_match_type =
+            password_manager_util::GetMatchType(best_match);
+        if (ref_match_type ==
+                password_manager_util::GetLoginMatchType::kExact ||
+            ref_match_type ==
+                password_manager_util::GetLoginMatchType::kAffiliated) {
+          return best_match_type == ref_match_type;
+        }
+        return true;
       });
 }
 
diff --git a/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.h b/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.h
index f993b4f..469e1f64 100644
--- a/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.h
+++ b/components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.h
@@ -68,9 +68,9 @@
   enum class FieldType { kUsername, kPassword };
 
   // Retrieves the full data of a saved credential for the form managed
-  // by `signin_form_manager` corresponding to `credential_`.
+  // by `reference_form_manager` corresponding to `credential_`.
   virtual const password_manager::StoredCredential* GetMatchingStoredCredential(
-      const password_manager::PasswordFormManager& signin_form_manager);
+      const password_manager::PasswordFormManager& reference_form_manager);
 
   virtual bool DoesStoredCredentialBelongToManager(
       const password_manager::PasswordFormManager* manager,
@@ -94,6 +94,15 @@
   void ProcessRetrievedForms(
       std::vector<password_manager::PasswordFormManager*> eligible_managers);
 
+  // If there are multiple forms on the page, one will be chosen as
+  // reference based on whether it's in the primary main frame, or whether
+  // the provided credential is a strong match for the form.
+  std::pair<password_manager::PasswordFormManager*,
+            const password_manager::StoredCredential*>
+  FindReferenceFormAndCredential(
+      const std::vector<password_manager::PasswordFormManager*>&
+          eligible_managers);
+
   // Checks if device reauthentication is required before filling.
   // If required, triggers reauthentication and, upon success, re-fetches
   // eligible forms to ensure freshness before filling all of them.
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 8188b1c..f31b70e8 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
@@ -1078,6 +1078,425 @@
 }
 
 TEST_P(ActorLoginCredentialFillerTest,
+       DoesNotFillSameSiteSiblingIframeIfExactMatchForTargetedIframe) {
+  const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  const url::Origin same_site_origin_1 =
+      url::Origin::Create(GURL("https://login.example.com"));
+  const url::Origin same_site_origin_2 =
+      url::Origin::Create(GURL("https://login2.example.com"));
+  const Credential credential =
+      CreateTestCredential(kTestUsername, same_site_origin_1.GetURL(), origin);
+  const FormData same_site_form_data_1 =
+      CreateSigninFormData(same_site_origin_1.GetURL());
+  const FormData same_site_form_data_2 =
Loading diff…

Regression Test / PoC

shipped with the fix
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 8188b1c..f31b70e8 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
@@ -1078,6 +1078,425 @@
 }
 
 TEST_P(ActorLoginCredentialFillerTest,
+       DoesNotFillSameSiteSiblingIframeIfExactMatchForTargetedIframe) {
+  const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  const url::Origin same_site_origin_1 =
+      url::Origin::Create(GURL("https://login.example.com"));
+  const url::Origin same_site_origin_2 =
+      url::Origin::Create(GURL("https://login2.example.com"));
+  const Credential credential =
+      CreateTestCredential(kTestUsername, same_site_origin_1.GetURL(), origin);
+  const FormData same_site_form_data_1 =
+      CreateSigninFormData(same_site_origin_1.GetURL());
+  const FormData same_site_form_data_2 =
+      CreateSigninFormData(same_site_origin_2.GetURL());
+
+  SetSavedCredential(&form_fetcher_, same_site_origin_1.GetURL(), kTestUsername,
+                     kTestPassword);
+
+  FakeFormFetcher sibling_form_fetcher;
+  PasswordForm sibling_psl_match = CreateSavedPasswordForm(
+      same_site_origin_1.GetURL(), kTestUsername, kTestPassword);
+  sibling_psl_match.match_type =
+      password_manager::PasswordForm::MatchType::kPSL;
+  sibling_form_fetcher.SetBestMatches({sibling_psl_match});
+
+  std::vector<std::unique_ptr<PasswordFormManager>> form_managers;
+  MockStubPasswordManagerDriver same_site_driver_1;
+  MockStubPasswordManagerDriver same_site_driver_2;
+  form_managers.push_back(
+      CreateFormManagerWithParsedForm(same_site_origin_1, same_site_form_data_1,
+                                      same_site_driver_1, form_fetcher_));
+  form_managers.push_back(CreateFormManagerWithParsedForm(
+      same_site_origin_2, same_site_form_data_2, same_site_driver_2,
+      sibling_form_fetcher));
+
+  const PasswordForm* parsed_form_1 = form_managers[0]->GetParsedObservedForm();
+
+  base::test::TestFuture<LoginStatusResultOrError> future;
+  auto filler = std::make_unique<ActorLoginCredentialFiller>(
+      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)));
+
+  ON_CALL(same_site_driver_1, IsNestedWithinFencedFrame)
+      .WillByDefault(Return(false));
+  ON_CALL(same_site_driver_1, IsDirectChildOfPrimaryMainFrame)
+      .WillByDefault(Return(true));
+  ON_CALL(same_site_driver_2, IsNestedWithinFencedFrame)
+      .WillByDefault(Return(false));
+  ON_CALL(same_site_driver_2, IsDirectChildOfPrimaryMainFrame)
+      .WillByDefault(Return(true));
+
+  ON_CALL(same_site_driver_1, CheckViewAreaVisible)
+      .WillByDefault(WithArg<1>(&PostResponse<true>));
+  ON_CALL(same_site_driver_2, CheckViewAreaVisible)
+      .WillByDefault(WithArg<1>(&PostResponse<true>));
+
+  EXPECT_CALL(
+      same_site_driver_1,
+      FillField(parsed_form_1->username_element_renderer_id, Eq(kTestUsername),
+                autofill::FieldPropertiesFlags::kAutofilledActorLogin, _))
+      .WillOnce(RunOnceCallback<3>(true));
+  EXPECT_CALL(
+      same_site_driver_1,
+      FillField(parsed_form_1->password_element_renderer_id, Eq(kTestPassword),
+                autofill::FieldPropertiesFlags::kAutofilledActorLogin, _))
+      .WillOnce(RunOnceCallback<3>(true));
+
+  EXPECT_CALL(same_site_driver_2, FillField).Times(0);
+
+  filler->AttemptLogin(&mock_password_manager_);
+  const LoginStatusResultOrError& result = future.Get();
+  ASSERT_TRUE(result.has_value());
+  EXPECT_EQ(result.value(),
+            LoginStatusResult::kSuccessUsernameAndPasswordFilled);
+}
+
+TEST_P(ActorLoginCredentialFillerTest,
+       DoesNotFillSameSiteSiblingIframeIfExactMatchForSecondIframe) {
+  const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  const url::Origin same_site_origin_1 =
+      url::Origin::Create(GURL("https://login.example.com"));
+  const url::Origin same_site_origin_2 =
+      url::Origin::Create(GURL("https://login2.example.com"));
+  const Credential credential =
+      CreateTestCredential(kTestUsername, same_site_origin_2.GetURL(), origin);
+  const FormData same_site_form_data_1 =
+      CreateSigninFormData(same_site_origin_1.GetURL());
+  const FormData same_site_form_data_2 =
+      CreateSigninFormData(same_site_origin_2.GetURL());
+
+  // Saved credential exists for same_site_origin_2 (exact match).
+  SetSavedCredential(&form_fetcher_, same_site_origin_2.GetURL(), kTestUsername,
+                     kTestPassword);
+
+  // Sibling iframe (same_site_origin_1) has a PSL match.
+  FakeFormFetcher sibling_form_fetcher;
+  PasswordForm sibling_psl_match = CreateSavedPasswordForm(
+      same_site_origin_2.GetURL(), kTestUsername, kTestPassword);
+  sibling_psl_match.match_type =
+      password_manager::PasswordForm::MatchType::kPSL;
+  sibling_form_fetcher.SetBestMatches({sibling_psl_match});
+
+  std::vector<std::unique_ptr<PasswordFormManager>> form_managers;
+  MockStubPasswordManagerDriver same_site_driver_1;
+  MockStubPasswordManagerDriver same_site_driver_2;
+  // Add same_site_origin_1 (the PSL match) first in the list of managers.
+  form_managers.push_back(CreateFormManagerWithParsedForm(
+      same_site_origin_1, same_site_form_data_1, same_site_driver_1,
+      sibling_form_fetcher));
+  form_managers.push_back(
+      CreateFormManagerWithParsedForm(same_site_origin_2, same_site_form_data_2,
+                                      same_site_driver_2, form_fetcher_));
+
+  const PasswordForm* parsed_form_2 = form_managers[1]->GetParsedObservedForm();
+
+  base::test::TestFuture<LoginStatusResultOrError> future;
+  auto filler = std::make_unique<ActorLoginCredentialFiller>(
+      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)));
+
+  ON_CALL(same_site_driver_1, IsNestedWithinFencedFrame)
+      .WillByDefault(Return(false));
+  ON_CALL(same_site_driver_1, IsDirectChildOfPrimaryMainFrame)
+      .WillByDefault(Return(true));
+  ON_CALL(same_site_driver_2, IsNestedWithinFencedFrame)
+      .WillByDefault(Return(false));
+  ON_CALL(same_site_driver_2, IsDirectChildOfPrimaryMainFrame)
+      .WillByDefault(Return(true));
+
+  ON_CALL(same_site_driver_1, CheckViewAreaVisible)
+      .WillByDefault(WithArg<1>(&PostResponse<true>));
+  ON_CALL(same_site_driver_2, CheckViewAreaVisible)
+      .WillByDefault(WithArg<1>(&PostResponse<true>));
+
+  // First iframe (same_site_driver_1) has a PSL match, should NOT be filled.
+  EXPECT_CALL(same_site_driver_1, FillField).Times(0);
+
+  // Second iframe (same_site_driver_2) has the exact match, should be filled.
+  EXPECT_CALL(
+      same_site_driver_2,
+      FillField(parsed_form_2->username_element_renderer_id, Eq(kTestUsername),
+                autofill::FieldPropertiesFlags::kAutofilledActorLogin, _))
+      .WillOnce(RunOnceCallback<3>(true));
+  EXPECT_CALL(
+      same_site_driver_2,
+      FillField(parsed_form_2->password_element_renderer_id, Eq(kTestPassword),
+                autofill::FieldPropertiesFlags::kAutofilledActorLogin, _))
+      .WillOnce(RunOnceCallback<3>(true));
+
+  filler->AttemptLogin(&mock_password_manager_);
+  const LoginStatusResultOrError& result = future.Get();
+  ASSERT_TRUE(result.has_value());
+  EXPECT_EQ(result.value(),
+            LoginStatusResult::kSuccessUsernameAndPasswordFilled);
+}
+
+TEST_P(ActorLoginCredentialFillerTest,
+       DoesNotFillSameSiteSiblingIframeIfAffiliatedMatchForTargetedIframe) {
+  const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  const url::Origin same_site_origin_1 =
+      url::Origin::Create(GURL("https://login.example.com"));
+  const url::Origin same_site_origin_2 =
+      url::Origin::Create(GURL("https://login2.example.com"));
+  const Credential credential =
+      CreateTestCredential(kTestUsername, same_site_origin_1.GetURL(), origin);
+  const FormData same_site_form_data_1 =
+      CreateSigninFormData(same_site_origin_1.GetURL());
+  const FormData same_site_form_data_2 =
+      CreateSigninFormData(same_site_origin_2.GetURL());
+
+  // Saved credential is an affiliated match for same_site_origin_1.
+  PasswordForm matching_form = CreateSavedPasswordForm(
+      same_site_origin_1.GetURL(), kTestUsername, kTestPassword);
+  matching_form.match_type =
+      password_manager::PasswordForm::MatchType::kAffiliated;
+  form_fetcher_.SetBestMatches({matching_form});
+
+  // Sibling iframe (same_site_origin_2) has a PSL match.
+  FakeFormFetcher sibling_form_fetcher;
+  PasswordForm sibling_psl_match = CreateSavedPasswordForm(
+      same_site_origin_1.GetURL(), kTestUsername, kTestPassword);
+  sibling_psl_match.match_type =
+      password_manager::PasswordForm::MatchType::kPSL;
+  sibling_form_fetcher.SetBestMatches({sibling_psl_match});
+
+  std::vector<std::unique_ptr<PasswordFormManager>> form_managers;
+  MockStubPasswordManagerDriver same_site_driver_1;
+  MockStubPasswordManagerDriver same_site_driver_2;
+  form_managers.push_back(
+      CreateFormManagerWithParsedForm(same_site_origin_1, same_site_form_data_1,
+                                      same_site_driver_1, form_fetcher_));
+  form_managers.push_back(CreateFormManagerWithParsedForm(
+      same_site_origin_2, same_site_form_data_2, same_site_driver_2,
+      sibling_form_fetcher));
+
+  const PasswordForm* parsed_form_1 = form_managers[0]->GetParsedObservedForm();
+
+  base::test::TestFuture<LoginStatusResultOrError> future;
+  auto filler = std::make_unique<ActorLoginCredentialFiller>(
+      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)));
+
+  ON_CALL(same_site_driver_1, IsNestedWithinFencedFrame)
+      .WillByDefault(Return(false));
+  ON_CALL(same_site_driver_1, IsDirectChildOfPrimaryMainFrame)
+      .WillByDefault(Return(true));
+  ON_CALL(same_site_driver_2, IsNestedWithinFencedFrame)
+      .WillByDefault(Return(false));
+  ON_CALL(same_site_driver_2, IsDirectChildOfPrimaryMainFrame)
+      .WillByDefault(Return(true));
+
+  ON_CALL(same_site_driver_1, CheckViewAreaVisible)
+      .WillByDefault(WithArg<1>(&PostResponse<true>));
+  ON_CALL(same_site_driver_2, CheckViewAreaVisible)
+      .WillByDefault(WithArg<1>(&PostResponse<true>));
+
+  // First iframe (same_site_driver_1) has an affiliated match, should be
+  // filled.
+  EXPECT_CALL(
+      same_site_driver_1,
+      FillField(parsed_form_1->username_element_renderer_id, Eq(kTestUsername),
+                autofill::FieldPropertiesFlags::kAutofilledActorLogin, _))
+      .WillOnce(RunOnceCallback<3>(true));
+  EXPECT_CALL(
+      same_site_driver_1,
+      FillField(parsed_form_1->password_element_renderer_id, Eq(kTestPassword),
+                autofill::FieldPropertiesFlags::kAutofilledActorLogin, _))
+      .WillOnce(RunOnceCallback<3>(true));
+
+  // Second iframe (same_site_driver_2) has a PSL match, should NOT be filled.
+  EXPECT_CALL(same_site_driver_2, FillField).Times(0);
+
+  filler->AttemptLogin(&mock_password_manager_);
+  const LoginStatusResultOrError& result = future.Get();
+  ASSERT_TRUE(result.has_value());
+  EXPECT_EQ(result.value(),
+            LoginStatusResult::kSuccessUsernameAndPasswordFilled);
+}
+
+TEST_P(
+    ActorLoginCredentialFillerTest,
+    DoesNotFillSameSiteSiblingIframeIfAffiliatedMatchAndExactMatchForTargetedIframe) {
+  const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
+  const url::Origin same_site_origin_1 =
+      url::Origin::Create(GURL("https://login.example.com"));
+  const url::Origin same_site_origin_2 =
+      url::Origin::Create(GURL("https://login2.example.com"));
+  const Credential credential =
+      CreateTestCredential(kTestUsername, same_site_origin_1.GetURL(), origin);
+  const FormData same_site_form_data_1 =
+      CreateSigninFormData(same_site_origin_1.GetURL());
+  const FormData same_site_form_data_2 =
+      CreateSigninFormData(same_site_origin_2.GetURL());
+
+  // Saved credential is an exact match for same_site_origin_1.
+  PasswordForm matching_form = CreateSavedPasswordForm(
+      same_site_origin_1.GetURL(), kTestUsername, kTestPassword);
+  matching_form.match_type = password_manager::PasswordForm::MatchType::kExact;
+  form_fetcher_.SetBestMatches({matching_form});
+
+  // Sibling iframe (same_site_origin_2) has an affiliated match.
+  FakeFormFetcher sibling_form_fetcher;
+  PasswordForm sibling_affiliated_match = CreateSavedPasswordForm(
+      same_site_origin_1.GetURL(), kTestUsername, kTestPassword);
+  sibling_affiliated_match.match_type =
+      password_manager::PasswordForm::MatchType::kAffiliated;
+  sibling_form_fetcher.SetBestMatches({sibling_affiliated_match});
+
+  std::vector<std::unique_ptr<PasswordFormManager>> form_managers;
+  MockStubPasswordManagerDriver same_site_driver_1;
+  MockStubPasswordManagerDriver same_site_driver_2;
+  form_managers.push_back(
+      CreateFormManagerWithParsedForm(same_site_origin_1, same_site_form_data_1,
+                                      same_site_driver_1, form_fetcher_));
+  form_managers.push_back(CreateFormManagerWithParsedForm(
+      same_site_origin_2, same_site_form_data_2, same_site_driver_2,
+      sibling_form_fetcher));
+
+  const PasswordForm* parsed_form_1 = form_managers[0]->GetParsedObservedForm();
+
+  base::test::TestFuture<LoginStatusResultOrError> future;
+  auto filler = std::make_unique<ActorLoginCredentialFiller>(
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential credential leak to sibling same-site iframe in ActorLogin due to unique-key collision

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential logic flaw in the ActorLogin credential filling mechanism may allow an attacker-controlled same-site sibling iframe to exfiltrate a user’s password. Because Public Suffix List (PSL)-matched credentials preserve their original database record’s fields (like signon_realm and url), their unique key is identical to exact-match credentials. As a result, the browser incorrectly considers the credential to belong to both the legitimate and attacker-controlled iframes, potentially filling the password into both.

Affected files:

  • components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
  • components/password_manager/core/browser/actor_login/internal/actor_login_form_finder.cc
  • components/password_manager/core/browser/password_store/get_logins_with_affiliations_request_handler.cc

Estimated timestamp from git blame: 2025-10-23

Description & Root Cause

A potential logical flaw in ActorLoginCredentialFiller::FillAllEligibleFields when kActorLogin is enabled may allow cross-origin credential disclosure to a same-site sibling iframe.

During credential filling, FillAllEligibleFields iterates through all eligible PasswordFormManager instances and checks whether the stored credential belongs to each manager via DoesStoredCredentialBelongToManager:

bool ActorLoginCredentialFiller::DoesStoredCredentialBelongToManager(
    const password_manager::PasswordFormManager* manager,
    const password_manager::StoredCredential& stored_credential) {
  return std::ranges::any_of(
      manager->GetBestMatches().begin(), manager->GetBestMatches().end(),
      [&stored_credential](const password_manager::StoredCredential& best_match) {
        return password_manager::AreStoredCredentialUniqueKeysEqual(
            stored_credential, best_match);
      });
}

AreStoredCredentialUniqueKeysEqual compares the StoredCredentialUniqueKey which consists of: std::tie(f.signon_realm, f.url, f.username_element, f.username_value, f.password_element)

When a credential stored for https://login.example.com is queried, a sibling iframe (e.g. https://evil.example.com) whose FormFetcher requests credentials will receive the store record as a PSL (Public Suffix List) match. In get_logins_with_affiliations_request_handler.cc (ProcessExactAndPSLForms), the match_type is updated to kPSL, but the original database fields (including signon_realm and url) are preserved verbatim.

Consequently, the unique key of the PSL-matched credential for evil.example.com is identical to the exact-match credential for login.example.com. Thus, the equality check evaluates to true for both the legitimate form manager and the attacker-controlled subdomain form manager. Both iframes are subsequently filled.

Origin Validation and Mitigation Bypass

  1. Origin Verification: IsValidFrameAndOriginToFill in actor_login_form_finder.cc allows filling direct children of the primary main frame that satisfy SameDomainOrHost(..., INCLUDE_PRIVATE_REGISTRIES). Sibling subdomains of the same eTLD+1 (e.g., login.example.com and evil.example.com) are both deemed eligible.
  2. Iframe Skip Bypass: The mitigation should_skip_iframes is only set to true when the signin form manager is located in the primary main frame. When the login interface is embedded in an iframe (a common single sign-on/federated widget pattern), should_skip_iframes is false, permitting both sibling iframes to be filled simultaneously.

Potential Steps to Reproduce

Since our tooling agent does not currently have the ability to run code, we have not verified this with a working Proof of Concept (PoC). However, the potential steps an attacker would follow to trigger this vulnerability are:

  1. A victim has saved credentials for https://login.example.com.
  2. An attacker controls https://evil.example.com (same eTLD+1).
  3. The attacker hosts a page on https://evil.example.com/page which embeds:
    • <iframe src="https://login.example.com/signin"> (legitimate iframe, which permits framing by same-site ancestors).
    • <iframe src="https://evil.example.com/form"> (attacker’s iframe containing a visible <input type="password">).
  4. The user utilizes the Actor login feature on https://evil.example.com/page and invokes AttemptLogin.
  5. The browser fetches credentials, matching both form managers. Due to the unique key collision, the browser determines that the stored credential belongs to both managers.
  6. Plaintext credentials are sent to both iframes via Mojo, allowing the attacker’s iframe to execute standard, unprivileged JavaScript (input.value) to read and exfiltrate the password.

Suggested Fix

To remediate this issue, DoesStoredCredentialBelongToManager should ensure that credentials containing distinct match types (such as comparing an exact-match record to a PSL-match record) are not conflated. The comparison logic should require that the credentials belong to the same subdomain and origin as the frame driver, rather than relying strictly on the original database-retained PSL match realms.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker