Low watch CVSS 5.4 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Passwords
DescriptionImproper input validation in Passwords
ComponentPasswords
Sub-componentUI>Browser>Passwords
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker553271219
Fix commit9ef7ab580cf2 (chromium/src) +78/-0
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-08-27 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-22
Patch gap25 days — fix public in source before the advisory

Background

`LoginStateChecker`
A password-change component that captures page content and runs an optimization-guide model to decide whether the user is logged in or out on the current page.
`DidFinishNavigation`
A content::WebContentsObserver override invoked by the browser for every navigation that finishes in the observed WebContents, including subframe and uncommitted (failed/aborted) navigations.
`content::NavigationHandle`
The object describing an in-flight navigation, exposing IsInPrimaryMainFrame() and HasCommitted() to distinguish a real top-level page load from subframe or non-committing events.
`capturer_`
The AnnotatedPageContentCapturer member whose reset-and-recheck cycle drives a fresh login-state evaluation after each navigation.

Root Cause Analysis

LoginStateChecker::DidFinishNavigation reset capturer_ and called CheckLoginState() on every DidFinishNavigation callback, without checking which frame the navigation targeted or whether it actually committed. Because WebContentsObserver::DidFinishNavigation fires for subframe navigations and for uncommitted navigations (e.g. an aborted or failed load), an attacker-controlled iframe or a spurious navigation event could repeatedly cancel the in-progress page-content capture and trigger a re-check against a page state that does not reflect the real committed main-frame document. The invariant that the login-state check should reason only about the primary main frame’s committed content was violated, letting non-authoritative navigation events drive password-change login detection.

The fix adds a guard that returns early unless navigation_handle->IsInPrimaryMainFrame() and navigation_handle->HasCommitted() are both true, so only genuine top-level, committed navigations reset the capturer and re-run the check. This restores the assumption that the checked content corresponds to the actual page the user is on.

Key insight
The single mistake was treating all DidFinishNavigation events as equivalent to a real main-frame page load; the fix filters out subframe and uncommitted navigations so the login-state re-check runs only for authoritative primary-main-frame commits.

Attack Path

  1. Initiate a password change The user or automated password-change flow starts on a target site, creating a LoginStateChecker that begins capturing page content to determine login state.
  2. Inject a subframe navigation A page with an attacker-influenced iframe (or one that issues aborted navigations) fires DidFinishNavigation for a non-main-frame or non-committed navigation.
  3. Cancel and re-trigger the check The unguarded handler resets capturer_ and calls CheckLoginState(), evaluating login state against content that does not correspond to the committed primary main frame. (inferred)
  4. Cause a misclassified login result The mismatched evaluation can yield an incorrect logged-in/logged-out determination, steering the password-change flow based on non-authoritative input. (inferred)

Impact Assessment

An attacker able to influence subframe or spurious navigations in the page under a password-change check could induce the LoginStateChecker to re-evaluate login state against non-authoritative content, causing the browser-process password-change logic to misjudge whether the user is logged in. This is a logic/improper-input-validation issue in the Passwords component running in the browser process, with the precondition that a password-change login check is active on an attacker-influenced page. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.4. Any escalation beyond incorrect login-state classification is not demonstrated by the patch. (inferred)

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
modified

Files Changed

  • chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
  • chrome/browser/password_manager/password_change/login_state_checker.cc

Audit Directions

  • Unfiltered navigation observers
    Review WebContentsObserver::DidFinishNavigation overrides that act on state without checking IsInPrimaryMainFrame() and HasCommitted(), since these fire for subframe and uncommitted navigations.
  • Security decisions on non-committed state
    Look for logic that captures page content or makes trust/login decisions on navigation callbacks before the target document has actually committed in the top-level frame.
  • Password and autofill navigation hooks
    Audit other Passwords/autofill components that reset capturers or re-run checks per navigation event to ensure they scope work to authoritative main-frame commits.
From 9ef7ab580cf24523c4995b2c1a66d1fa58811e2a Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <[email protected]>
Date: Fri, 28 Aug 2026 06:13:07 -0700
Subject: [PATCH] Add main-frame and committed-navigation checks to LoginStateChecker

Fix: b/553271219
Change-Id: Ic8aa835fb2d7a077aa66438d323a16e58f02fdda
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8306073
Reviewed-by: Anna Tsvirchkova <[email protected]>
Commit-Queue: Viktor Semeniuk <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1687939}
---
diff --git a/chrome/browser/password_manager/password_change/login_state_checker.cc b/chrome/browser/password_manager/password_change/login_state_checker.cc
index 322dc506..0a021302 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker.cc
@@ -159,6 +159,10 @@
 
 void LoginStateChecker::DidFinishNavigation(
     content::NavigationHandle* navigation_handle) {
+  if (navigation_handle && (!navigation_handle->IsInPrimaryMainFrame() ||
+                            !navigation_handle->HasCommitted())) {
+    return;
+  }
   capturer_.reset();
   CheckLoginState(/*ignore_attempts_limit=*/false);
 }
diff --git a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
index ec93f15..9de090f 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
@@ -22,7 +22,10 @@
 #include "components/password_manager/core/browser/features/password_features.h"
 #include "components/password_manager/core/browser/password_manager_metrics_util.h"
 #include "components/password_manager/core/browser/stub_password_manager_client.h"
+#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.h"
 #include "content/public/test/web_contents_tester.h"
+#include "net/base/net_errors.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
@@ -637,3 +640,74 @@
       "PasswordManager.PasswordChange.LoginCheckDuration",
       kExpectedDurationTime, 1);
 }
+
+TEST_F(LoginStateCheckerTest, SubframeNavigationIgnored) {
+  base::test::TestFuture<LoginCheckResult> future;
+  EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+  NavigateAndCommit(GURL("https://example.com"));
+
+  std::unique_ptr<LoginStateChecker> checker =
+      CreateChecker(future.GetRepeatingCallback());
+  ASSERT_TRUE(checker->capturer());
+
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendChild("child_frame");
+  content::NavigationSimulator::NavigateAndCommitFromDocument(
+      GURL("https://example.com/subframe"), child_rfh);
+
+  // Capturer is not reset because subframe navigation is ignored.
+  EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest, UncommittedNavigationIgnored) {
+  base::test::TestFuture<LoginCheckResult> future;
+  EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+  NavigateAndCommit(GURL("https://example.com"));
+
+  std::unique_ptr<LoginStateChecker> checker =
+      CreateChecker(future.GetRepeatingCallback());
+  ASSERT_TRUE(checker->capturer());
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      GURL("https://example.com/failed"), web_contents());
+  simulator->Fail(net::ERR_ABORTED);
+
+  // Capturer is not reset because uncommitted navigation is ignored.
+  EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest,
+       PrimaryMainFrameCommittedNavigationTriggersRecheck) {
+  base::test::TestFuture<LoginCheckResult> future;
+  {
+    InSequence s;
+    EXPECT_CALL(*optimization_service(), ExecuteModel)
+        .WillOnce(WithArg<3>(&PostResponse<ResponseType::kFailure>));
+    EXPECT_CALL(*optimization_service(), ExecuteModel)
+        .WillOnce(WithArg<3>(&PostResponse<ResponseType::kSuccess>));
+  }
+
+  NavigateAndCommit(GURL("https://example.com"));
+
+  std::unique_ptr<LoginStateChecker> checker =
+      CreateChecker(future.GetRepeatingCallback());
+  ASSERT_TRUE(checker->capturer());
+
+  static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+      ->SimulateResponse(optimization_guide::AIPageContentResult());
+  EXPECT_THAT(future.Take(),
+              HasLoginCheckStatus(LoginCheckResult::Status::kLoggedOut));
+
+  // Primary main frame committed navigation.
+  content::NavigationSimulator::NavigateAndCommitFromBrowser(
+      web_contents(), GURL("https://example.com/next"));
+
+  ASSERT_TRUE(checker->capturer());
+  static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+      ->SimulateResponse(optimization_guide::AIPageContentResult());
+  EXPECT_THAT(future.Take(),
+              HasLoginCheckStatus(LoginCheckResult::Status::kLoggedIn));
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
index ec93f15..9de090f 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
@@ -22,7 +22,10 @@
 #include "components/password_manager/core/browser/features/password_features.h"
 #include "components/password_manager/core/browser/password_manager_metrics_util.h"
 #include "components/password_manager/core/browser/stub_password_manager_client.h"
+#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.h"
 #include "content/public/test/web_contents_tester.h"
+#include "net/base/net_errors.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
@@ -637,3 +640,74 @@
       "PasswordManager.PasswordChange.LoginCheckDuration",
       kExpectedDurationTime, 1);
 }
+
+TEST_F(LoginStateCheckerTest, SubframeNavigationIgnored) {
+  base::test::TestFuture<LoginCheckResult> future;
+  EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+  NavigateAndCommit(GURL("https://example.com"));
+
+  std::unique_ptr<LoginStateChecker> checker =
+      CreateChecker(future.GetRepeatingCallback());
+  ASSERT_TRUE(checker->capturer());
+
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendChild("child_frame");
+  content::NavigationSimulator::NavigateAndCommitFromDocument(
+      GURL("https://example.com/subframe"), child_rfh);
+
+  // Capturer is not reset because subframe navigation is ignored.
+  EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest, UncommittedNavigationIgnored) {
+  base::test::TestFuture<LoginCheckResult> future;
+  EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+  NavigateAndCommit(GURL("https://example.com"));
+
+  std::unique_ptr<LoginStateChecker> checker =
+      CreateChecker(future.GetRepeatingCallback());
+  ASSERT_TRUE(checker->capturer());
+
+  auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+      GURL("https://example.com/failed"), web_contents());
+  simulator->Fail(net::ERR_ABORTED);
+
+  // Capturer is not reset because uncommitted navigation is ignored.
+  EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest,
+       PrimaryMainFrameCommittedNavigationTriggersRecheck) {
+  base::test::TestFuture<LoginCheckResult> future;
+  {
+    InSequence s;
+    EXPECT_CALL(*optimization_service(), ExecuteModel)
+        .WillOnce(WithArg<3>(&PostResponse<ResponseType::kFailure>));
+    EXPECT_CALL(*optimization_service(), ExecuteModel)
+        .WillOnce(WithArg<3>(&PostResponse<ResponseType::kSuccess>));
+  }
+
+  NavigateAndCommit(GURL("https://example.com"));
+
+  std::unique_ptr<LoginStateChecker> checker =
+      CreateChecker(future.GetRepeatingCallback());
+  ASSERT_TRUE(checker->capturer());
+
+  static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+      ->SimulateResponse(optimization_guide::AIPageContentResult());
+  EXPECT_THAT(future.Take(),
+              HasLoginCheckStatus(LoginCheckResult::Status::kLoggedOut));
+
+  // Primary main frame committed navigation.
+  content::NavigationSimulator::NavigateAndCommitFromBrowser(
+      web_contents(), GURL("https://example.com/next"));
+
+  ASSERT_TRUE(checker->capturer());
+  static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+      ->SimulateResponse(optimization_guide::AIPageContentResult());
+  EXPECT_THAT(future.Take(),
+              HasLoginCheckStatus(LoginCheckResult::Status::kLoggedIn));
+}
Loading diff…

Original Bug Report

reported by [email protected]

Subframe navigations and loads can stall LoginStateChecker in Automated Password Change

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: Missing frame filters in LoginStateChecker::DidFinishNavigation and an un-timeouted tab-wide loading gate in AnnotatedPageContentCapturerImpl allow embedded third-party iframes to starve page content capture. Under the default configuration, an uncompromised cross-origin iframe can permanently stall consented Automated Password Change while displaying an active progress toast. Under the private inference configuration, it can cause the check to time out and silently terminate without displaying any compromised-credential warning.

Affected files:

  • chrome/browser/password_manager/password_change/login_state_checker.cc
  • chrome/browser/password_manager/password_change/annotated_page_content_capturer_impl.cc

Estimated timestamp from git blame: 2025-08-12

Potential Issue: Third-Party Iframes Can Starve LoginStateChecker and Stall Automated Password Change

Summary

In the Automated Password Change (APC) login-state verification machinery, two mechanisms permit any subframe (such as a third-party ad or widget iframe) in the tab to indefinitely prevent LoginStateChecker from completing:

  1. Unfiltered LoginStateChecker::DidFinishNavigation (chrome/browser/password_manager/password_change/login_state_checker.cc:160-164):

    void LoginStateChecker::DidFinishNavigation(
        content::NavigationHandle* navigation_handle) {
      capturer_.reset();
      CheckLoginState(/*ignore_attempts_limit=*/false);
    }
    

    WebContentsObserver::DidFinishNavigation fires for all navigations across all frames in the WebContents, including same-document navigations and aborted navigations. Because navigation_handle->IsInPrimaryMainFrame() and navigation_handle->HasCommitted() are not checked, any subframe navigation (e.g. hash-change or URL update) destroys the current AnnotatedPageContentCapturerImpl and restarts the check. The attempts counter (state_checks_count_) only increments upon receiving a model response (line 256), so repeated subframe navigations never exhaust kMaxLoginChecks = 5.

  2. Capture Start Gated on Tab-Wide IsLoading() Without Timeout (chrome/browser/password_manager/password_change/annotated_page_content_capturer_impl.cc:46-71): When kAwaitPageStabilityForPasswordChange is disabled (default), AnnotatedPageContentCapturerImpl waits for web_contents()->IsLoading() to become false before starting content capture (OnPageStable()). WebContentsImpl::IsLoading() checks the primary frame tree including ad subframes (content/browser/web_contents/web_contents_impl.cc:2649-2651). A subframe with an ongoing or hanging subresource load keeps tab loading in LoadingState::LOADING_WITHOUT_UI (content/browser/renderer_host/frame_tree.cc:868-890), preventing DidStopLoading() from ever firing. Furthermore, LoginStateChecker::StartTimeoutTimer() only arms a timeout timer when service_type_ == ModelExecutionServiceType::kPrivateAi (login_state_checker.cc:166-175), leaving the default flow (kDefault) with no timeout.

Potential Impact

  • Default Configuration: When the user consents to automated password rotation for a compromised credential, PasswordChangeDelegateImpl enters State::kWaitingForChangePasswordForm (displaying the “Checking sign-in…” toast). Starvation causes the flow to stall permanently in this state without ever executing actuator_->Start(). The compromised password remains in place while the user is falsely reassured by the progress indicator.
  • kPasswordChangeWithPrivateInferenceLoginCheck Enabled Configuration: The delegate initializes LoginStateChecker with kPrivateAi before displaying any UI. Starvation causes the 5-minute kLoginCheckTimeout timer to expire, returning LoginCheckResult::Status::kError, which causes PasswordChangeDelegateImpl::OnLoginStateCheckedWithPIResult() to call Stop(); ResetInternalState();, deleting the delegate. Because ManagePasswordsUIController::OnCredentialLeak() already routed to APC without creating the fallback CredentialLeakDialogControllerImpl, the user is never shown any alert about their leaked credential.

(Note: These are potential steps and impacts identified through static code auditing; automated runtime confirmation was not performed.)

Potential Trigger Steps

  1. A victim user with an APC-eligible profile logs into a site with an affiliated password-change URL using a credential that triggers leak detection.
  2. The site embeds a cross-origin iframe (e.g. an ad slot).
  3. The attacker-controlled iframe either:
    • Keeps a hanging subresource request open (preventing WebContents::IsLoading() from returning false), and/or
    • Periodically triggers same-document or subframe navigations (e.g., updating location.hash or history.pushState()).
  4. Chrome triggers the APC flow on credential leak.
  5. Observe that LoginStateChecker fails to deliver a page capture verdict, resulting in either a permanent stall of the APC flow (default config) or silent delegate termination suppressing the leak warning (flag-enabled config).

Suggested Fix

  1. Add main-frame and committed-navigation checks to LoginStateChecker::DidFinishNavigation matching PasswordChangePageStabilityWaiter::DidFinishNavigation (chrome/browser/password_manager/password_change/password_change_page_stability_waiter.cc:116-121):
    void LoginStateChecker::DidFinishNavigation(
        content::NavigationHandle* navigation_handle) {
      if (navigation_handle && (!navigation_handle->IsInPrimaryMainFrame() ||
                                !navigation_handle->HasCommitted())) {
        return;
      }
      capturer_.reset();
      CheckLoginState(/*ignore_attempts_limit=*/false);
    }
    
  2. Add a fallback timeout or utilize PasswordChangePageStabilityWaiter across all capture paths rather than relying on un-timeouted tab-wide IsLoading().

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


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