Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Password Manager
DescriptionInappropriate implementation in Password Manager
ComponentPassword Manager
Bug ClassLogic Error
Tracker500095743
Fix commitb0e1a2c49be0 (chromium/src) +69/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc
modified

Files Changed

  • chrome/browser/actor/tools/attempt_login_tool.cc
  • chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc
  • chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.h
  • chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
  • components/actor/public/mojom/actor_types.mojom
  • components/password_manager/core/browser/actor_login/actor_login_types.h
  • components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
  • components/password_manager/core/browser/actor_login/internal/actor_login_metrics.cc
  • components/password_manager/core/browser/actor_login/internal/actor_login_metrics.h
From b0e1a2c49be031f354993638bc3d1cff01e9f4f2 Mon Sep 17 00:00:00 2001
From: Ioana Pandele <ioanap@chromium.org>
Date: Mon, 27 Apr 2026 05:47:20 -0700
Subject: [PATCH] [ActorLogin] Abort password filling if primary page changed

This CL will interrupt the filling by simply destroying the
credential filler if the primary page changes. This is preferable to
checking whether the origin changed immediately before filling since
it can cancel the process at any step.

Bug: 500095743
Change-Id: Ie2ff65298d75c73189a2bc8628ed2433720f8a45
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7768137
Commit-Queue: Ioana Treib <ioanap@chromium.org>
Reviewed-by: Mike West <mkwst@chromium.org>
Reviewed-by: Oleksandr Tara <otara@google.com>
Reviewed-by: Kevin McNee <mcnee@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1621023}
---

diff --git a/chrome/browser/actor/tools/attempt_login_tool.cc b/chrome/browser/actor/tools/attempt_login_tool.cc
index f269541..5bac08af 100644
--- a/chrome/browser/actor/tools/attempt_login_tool.cc
+++ b/chrome/browser/actor/tools/attempt_login_tool.cc
@@ -114,6 +114,8 @@
     case actor_login::LoginStatusResult::kRequiresButtonClick:
       // TODO(crbug.com/479505793): Consider adding a more specific error code.
       return mojom::ActionResultCode::kArgumentsInvalid;
+    case actor_login::LoginStatusResult::kErrorPageChangedDuringFilling:
+      return mojom::ActionResultCode::kLoginPasswordFillingPageChanged;
   }
 }
 
diff --git a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc
index 2b6b5d9..42f4e46 100644
--- a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc
+++ b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc
@@ -284,6 +284,14 @@
   client_ = nullptr;
 }
 
+void ActorLoginDelegateImpl::PrimaryPageChanged(content::Page& page) {
+  // If the page changed while trying to fill in passwords,
+  // terminate the operation.
+  if (credential_filler_) {
+    OnAttemptLoginCompleted(LoginStatusResult::kErrorPageChangedDuringFilling);
+  }
+}
+
 bool ActorLoginDelegateImpl::IsTaskInFocus() {
   // This `WebContents` comes from the `TabInterface` that
   // `ActorLoginService` is invoked with, so we know the `WebContents` is
diff --git a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.h b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.h
index 72adb5b..d65e9f91 100644
--- a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.h
+++ b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.h
@@ -15,6 +15,7 @@
 #include "components/password_manager/core/browser/password_form.h"
 #include "components/password_manager/core/browser/password_manager_driver.h"
 #include "components/password_manager/core/browser/password_manager_interface.h"
+#include "content/public/browser/page.h"
 #include "content/public/browser/web_contents_user_data.h"
 
 namespace password_manager {
@@ -71,6 +72,10 @@
   void OnLoginSuccessful(
       const password_manager::PasswordForm& pending_form) override;
 
+  // content::WebContentsObserver implementation:
+  void WebContentsDestroyed() override;
+  void PrimaryPageChanged(content::Page& page) override;
+
  private:
   friend class content::WebContentsUserData<ActorLoginDelegateImpl>;
 
@@ -82,9 +87,6 @@
       password_manager::PasswordManagerClient* client,
       PasswordDriverSupplierForPrimaryMainFrame driver_supplier);
 
-  // content::WebContentsObserver:
-  void WebContentsDestroyed() override;
-
   // Checks whether the currently ongoing task is in focus, either in
   // the tab or in its corresponding Glic UI instance.
   bool IsTaskInFocus();
diff --git a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
index c7f107a4..5220ed14 100644
--- a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
+++ b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
@@ -1620,4 +1620,44 @@
   std::move(captured_callback).Run(/*success=*/false);
 }
 
+TEST_F(ActorLoginDelegateImplTest,
+       PrimaryPageChangedDuringPasswordAttemptLogin) {
+  base::test::ScopedFeatureList feature_list(
+      password_manager::features::kActorLogin);
+  GURL url = GURL(kTestUrl);
+  url::Origin origin = url::Origin::Create(url);
+  const Credential credential =
+      CreateTestCredential(kTestUsername, url, origin);
+  const autofill::FormData form_data = CreateSigninFormData(url);
+
+  std::vector<password_manager::PasswordForm> saved_forms;
+  saved_forms.push_back(CreateSavedPasswordForm(url, kTestUsername));
+  form_fetcher_.SetBestMatches(saved_forms);
+
+  ON_CALL(mock_driver_, GetLastCommittedOrigin())
+      .WillByDefault(ReturnRef(origin));
+  ON_CALL(mock_driver_, IsInPrimaryMainFrame).WillByDefault(Return(true));
+  ON_CALL(mock_driver_, IsNestedWithinFencedFrame).WillByDefault(Return(false));
+
+  form_managers_.clear();
+  form_managers_.push_back(
+      CreateFormManagerWithParsedForm(origin, form_data, mock_driver_));
+
+  SetUpActorCredentialFillerDeps();
+  EXPECT_CALL(mock_form_cache_, GetFormManagers)
+      .WillRepeatedly(Return(base::span(form_managers_)));
+
+  base::test::TestFuture<LoginStatusResultOrError> future;
+  delegate_->AttemptLogin(credential, false, mqls_logger(),
+                          base::TimeTicks::Now(), future.GetCallback(),
+                          /*action_sequence_delegate=*/nullptr);
+
+  // Trigger `PrimaryPageChanged` before the message loop runs.
+  delegate_->PrimaryPageChanged(delegate_->web_contents()->GetPrimaryPage());
+
+  ASSERT_TRUE(future.Get().has_value());
+  EXPECT_EQ(future.Get().value(),
+            LoginStatusResult::kErrorPageChangedDuringFilling);
+}
+
 }  // namespace actor_login
diff --git a/components/actor/public/mojom/actor_types.mojom b/components/actor/public/mojom/actor_types.mojom
index 8d0f360f..b5da0bb 100644
--- a/components/actor/public/mojom/actor_types.mojom
+++ b/components/actor/public/mojom/actor_types.mojom
@@ -364,6 +364,9 @@
   // The federated login request timed out.
   kLoginFederatedTimeout = 919,
 
+  // The primary page changed while password filling was in progress.
+  kLoginPasswordFillingPageChanged = 920,
+
   // Please see the comment above about adding new values.
 
   ///////////////////////////////////////////////////////////////////////
diff --git a/components/password_manager/core/browser/actor_login/actor_login_types.h b/components/password_manager/core/browser/actor_login/actor_login_types.h
index 4f9ef83..0118428 100644
--- a/components/password_manager/core/browser/actor_login/actor_login_types.h
+++ b/components/password_manager/core/browser/actor_login/actor_login_types.h
@@ -154,6 +154,8 @@
   kErrorDeviceReauthRequired,
   // Returned if the device re-authentication fails.
   kErrorDeviceReauthFailed,
+  // Returned if the page navigated away while filling was pending.
+  kErrorPageChangedDuringFilling,
 
   // Attempt login statuses using federated credentials.
   //
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 ff47499..7bdd6e6 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
@@ -538,6 +538,7 @@
     case LoginStatusResult::kErrorFederatedExpectedAccountNotPresent:
     case LoginStatusResult::kErrorFederatedTimeout:
     case LoginStatusResult::kRequiresButtonClick:
+    case LoginStatusResult::kErrorPageChangedDuringFilling:
       NOTREACHED();
   }
 
diff --git a/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.cc b/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.cc
index 8ebe94b..123c244 100644
--- a/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.cc
+++ b/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.cc
@@ -119,6 +119,10 @@
       case LoginStatusResult::kRequiresButtonClick:
         RecordAttemptLoginResult(AttemptLoginResult::kRequiresButtonClick);
         break;
+      case LoginStatusResult::kErrorPageChangedDuringFilling:
+        RecordAttemptLoginResult(
+            AttemptLoginResult::kErrorPageChangedDuringFilling);
+        break;
     }
     return;
   }
diff --git a/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.h b/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.h
index 2cad766..e12ca3d 100644
--- a/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.h
+++ b/components/password_manager/core/browser/actor_login/internal/actor_login_metrics.h
@@ -56,7 +56,8 @@
   kErrorFederatedExpectedAccountNotPresent = 21,
   kErrorFederatedTimeout = 22,
   kRequiresButtonClick = 23,
-  kMaxValue = kRequiresButtonClick,
+  kErrorPageChangedDuringFilling = 24,
+  kMaxValue = kErrorPageChangedDuringFilling,
 };
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
index c7f107a4..5220ed14 100644
--- a/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
+++ b/chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cc
@@ -1620,4 +1620,44 @@
   std::move(captured_callback).Run(/*success=*/false);
 }
 
+TEST_F(ActorLoginDelegateImplTest,
+       PrimaryPageChangedDuringPasswordAttemptLogin) {
+  base::test::ScopedFeatureList feature_list(
+      password_manager::features::kActorLogin);
+  GURL url = GURL(kTestUrl);
+  url::Origin origin = url::Origin::Create(url);
+  const Credential credential =
+      CreateTestCredential(kTestUsername, url, origin);
+  const autofill::FormData form_data = CreateSigninFormData(url);
+
+  std::vector<password_manager::PasswordForm> saved_forms;
+  saved_forms.push_back(CreateSavedPasswordForm(url, kTestUsername));
+  form_fetcher_.SetBestMatches(saved_forms);
+
+  ON_CALL(mock_driver_, GetLastCommittedOrigin())
+      .WillByDefault(ReturnRef(origin));
+  ON_CALL(mock_driver_, IsInPrimaryMainFrame).WillByDefault(Return(true));
+  ON_CALL(mock_driver_, IsNestedWithinFencedFrame).WillByDefault(Return(false));
+
+  form_managers_.clear();
+  form_managers_.push_back(
+      CreateFormManagerWithParsedForm(origin, form_data, mock_driver_));
+
+  SetUpActorCredentialFillerDeps();
+  EXPECT_CALL(mock_form_cache_, GetFormManagers)
+      .WillRepeatedly(Return(base::span(form_managers_)));
+
+  base::test::TestFuture<LoginStatusResultOrError> future;
+  delegate_->AttemptLogin(credential, false, mqls_logger(),
+                          base::TimeTicks::Now(), future.GetCallback(),
+                          /*action_sequence_delegate=*/nullptr);
+
+  // Trigger `PrimaryPageChanged` before the message loop runs.
+  delegate_->PrimaryPageChanged(delegate_->web_contents()->GetPrimaryPage());
+
+  ASSERT_TRUE(future.Get().has_value());
+  EXPECT_EQ(future.Get().value(),
+            LoginStatusResult::kErrorPageChangedDuringFilling);
+}
+
 }  // namespace actor_login
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential TOCTOU in Actor Login allows same-site cross-origin credential leak

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 without the security team.

Overview: A potential Time-Of-Check to Time-Of-Use (TOCTOU) vulnerability exists in the Actor Login feature during biometric re-authentication. A background navigation to a same-eTLD+1 attacker origin while the authentication prompt is open can cause credentials to be filled into the attacker’s page. This occurs because the initial page origin is cached, and post-authentication checks erroneously authorize the new primary main frame due to permissive same-site iframe support logic.

Affected files:

  • components/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cc
  • chrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc
  • components/password_manager/core/browser/actor_login/internal/actor_login_form_finder.cc

Estimated timestamp from git blame: 2025-12-17

Description

A potential Time-Of-Check To Time-Of-Use (TOCTOU) vulnerability exists in the Actor Login feature. When biometric re-authentication is required prior to filling credentials, the browser displays an OS-level modal prompt. This creates an asynchronous window during which the tab can be navigated in the background (e.g., by a malicious opener tab).

If the tab navigates to an attacker-controlled origin sharing the same eTLD+1 (e.g., from login.example.com to attacker.example.com), the Actor Login components fail to detect the navigation. Upon successful authentication, the process resumes using the stale origin state, bypassing secondary safety checks, and delivers the victim’s cleartext credentials to the attacker’s page.

Root Cause Analysis

  1. Stale Origin Caching: ActorLoginDelegateImpl::AttemptLogin captures the primary main frame’s origin and passes it to ActorLoginCredentialFiller, which stores it as a constant origin_. ActorLoginDelegateImpl does not implement PrimaryPageChanged, meaning navigations do not reset or abort the ongoing login attempt.
  2. Re-authentication Gap: ActorLoginCredentialFiller::AttemptReauth triggers the biometric prompt. While the UI blocks the user, background JavaScript execution continues, allowing window.opener to navigate the target tab.
  3. Post-Auth Re-evaluation Flaw: After authentication, FetchEligibleForms re-queries the PasswordFormCache, which now contains the newly parsed forms from the attacker’s navigated page.
  4. Validation Bypass: ActorLoginFormFinder::IsValidFrameAndOriginToFill checks if the new form is eligible. Because kActorLoginSameSiteIframeSupport is enabled, IsFormOriginSupported allows same-site (PSL) matches. The method then returns true because driver->IsInPrimaryMainFrame() is true for the new page.
  5. Credential Match Bypass: Finally, ActorLoginCredentialFiller::FillAllEligibleFields attempts to verify the credential belongs to the new frame by checking !stored_credential_belongs_to_manager using ArePasswordFormUniqueKeysEqual. Because the PasswordFormManager provides PSL matches unmodified directly from the database (retaining their original url and signon_realm), this check perfectly matches the stale credential. The cleartext password is subsequently filled into the attacker’s frame.

Potential Reproduction Steps

Note: These are suggested steps to trigger the vulnerability based on code analysis; our tooling agent does not run code to dynamically verify.

  1. Ensure kActorLogin is enabled and the user has enabled biometric authentication before filling.
  2. The user must have a saved credential for a victim origin, e.g., https://login.example.com/.
  3. The user visits an attacker-controlled page at https://attacker.example.com/start.
  4. The attacker’s page executes let target = window.open('https://login.example.com/signin');.
  5. The user initiates the Actor Login flow on the newly opened victim tab.
  6. The OS biometric prompt appears, legitimately asking the user to authenticate for login.example.com.
  7. While the prompt is active, the attacker’s opener page triggers a navigation on the victim tab: target.location = 'https://attacker.example.com/capture';.
  8. The user completes the biometric authentication.
  9. Chrome resumes the fill process, evaluates the new page, and erroneously delivers the cleartext credential to the forms on attacker.example.com.

Suggested Fix

  1. Cancel on Navigation: ActorLoginDelegateImpl should observe WebContentsObserver::PrimaryPageChanged. If the primary page navigates while an actor login task is ongoing, the process and its associated ActorLoginCredentialFiller should be immediately aborted and reset.
  2. Strict Post-Auth Validation: In ActorLoginCredentialFiller::OnDeviceReauthCompleted, explicitly verify that the current LastCommittedOrigin of the primary main frame exactly matches the cached origin_ before re-fetching forms.

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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