CVE-2026-11083
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cc |
modified |
Files Changed
chrome/browser/actor/tools/attempt_login_tool.ccchrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.ccchrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.hchrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl_unittest.cccomponents/actor/public/mojom/actor_types.mojomcomponents/password_manager/core/browser/actor_login/actor_login_types.hcomponents/password_manager/core/browser/actor_login/internal/actor_login_credential_filler.cccomponents/password_manager/core/browser/actor_login/internal/actor_login_metrics.cccomponents/password_manager/core/browser/actor_login/internal/actor_login_metrics.h
Patch
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,
};
Regression Test / PoC
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
Original Bug Report
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.ccchrome/browser/password_manager/actor_login/internal/actor_login_delegate_impl.cccomponents/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
- Stale Origin Caching:
ActorLoginDelegateImpl::AttemptLogincaptures the primary main frame’s origin and passes it toActorLoginCredentialFiller, which stores it as a constantorigin_.ActorLoginDelegateImpldoes not implementPrimaryPageChanged, meaning navigations do not reset or abort the ongoing login attempt. - Re-authentication Gap:
ActorLoginCredentialFiller::AttemptReauthtriggers the biometric prompt. While the UI blocks the user, background JavaScript execution continues, allowingwindow.openerto navigate the target tab. - Post-Auth Re-evaluation Flaw: After authentication,
FetchEligibleFormsre-queries thePasswordFormCache, which now contains the newly parsed forms from the attacker’s navigated page. - Validation Bypass:
ActorLoginFormFinder::IsValidFrameAndOriginToFillchecks if the new form is eligible. BecausekActorLoginSameSiteIframeSupportis enabled,IsFormOriginSupportedallows same-site (PSL) matches. The method then returns true becausedriver->IsInPrimaryMainFrame()is true for the new page. - Credential Match Bypass: Finally,
ActorLoginCredentialFiller::FillAllEligibleFieldsattempts to verify the credential belongs to the new frame by checking!stored_credential_belongs_to_managerusingArePasswordFormUniqueKeysEqual. Because thePasswordFormManagerprovides PSL matches unmodified directly from the database (retaining their originalurlandsignon_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.
- Ensure
kActorLoginis enabled and the user has enabled biometric authentication before filling. - The user must have a saved credential for a victim origin, e.g.,
https://login.example.com/. - The user visits an attacker-controlled page at
https://attacker.example.com/start. - The attacker’s page executes
let target = window.open('https://login.example.com/signin');. - The user initiates the Actor Login flow on the newly opened victim tab.
- The OS biometric prompt appears, legitimately asking the user to authenticate for
login.example.com. - While the prompt is active, the attacker’s opener page triggers a navigation on the victim tab:
target.location = 'https://attacker.example.com/capture';. - The user completes the biometric authentication.
- Chrome resumes the fill process, evaluates the new page, and erroneously delivers the cleartext credential to the forms on
attacker.example.com.
Suggested Fix
- Cancel on Navigation:
ActorLoginDelegateImplshould observeWebContentsObserver::PrimaryPageChanged. If the primary page navigates while an actor login task is ongoing, the process and its associatedActorLoginCredentialFillershould be immediately aborted and reset. - Strict Post-Auth Validation: In
ActorLoginCredentialFiller::OnDeviceReauthCompleted, explicitly verify that the currentLastCommittedOriginof the primary main frame exactly matches the cachedorigin_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.