Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Passwords
DescriptionInappropriate implementation in Passwords
ComponentPasswords
Bug ClassLogic Error
Tracker521491778
Fix commit34bdbf97263d (chromium/src) +72/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
chrome/browser/password_manager/chrome_password_manager_client.cc
modified

Files Changed

  • chrome/browser/password_manager/chrome_password_manager_client.cc
  • chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
From 34bdbf97263d24fad52ed15eedea8f328311f78d Mon Sep 17 00:00:00 2001
From: Maria Kazinova <kazinova@google.com>
Date: Thu, 25 Jun 2026 10:09:54 -0700
Subject: [PATCH] [Passwords] Stop prediction propagation for invalid URLs

This CL prevents the propagation of autofill predictions to the
Password Manager for frames that do not have a valid URL (e.g.,
opaque-origin frames).

Fixed: 521491778
Change-Id: I835dcc99d07b396ada155c17e647e522b8cf38ce
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8003700
Reviewed-by: Mohamed Amir Yosef <mamir@chromium.org>
Commit-Queue: Maria Kazinova <kazinova@google.com>
Cr-Commit-Position: refs/heads/main@{#1652519}
---

diff --git a/chrome/browser/password_manager/chrome_password_manager_client.cc b/chrome/browser/password_manager/chrome_password_manager_client.cc
index 84c3e0e..6fa8480 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client.cc
@@ -2007,7 +2007,7 @@
     auto* driver =
         password_manager::ContentPasswordManagerDriver::GetForRenderFrameHost(
             rfh);
-    if (!driver) {
+    if (!driver || !driver->HasValidURL(/*may_kill_renderer=*/false)) {
       continue;
     }
 
diff --git a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
index fe3f689..8f030c2f 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
@@ -846,7 +846,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   // Simulate that the field types have been determined, since server
@@ -962,7 +962,7 @@
                                              /*removed_forms=*/{});
     child_driver->renderer_events().FormsSeen(/*updated_forms=*/{child_form},
                                               /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/2));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/2));
   }
 
   // Simulate that the field types have been determined, since server
@@ -994,6 +994,72 @@
 }
 
 TEST_F(ChromePasswordManagerClientTest,
+       PasswordManagerDoesNotReceiveAutofillPredictionsFromOpaqueOriginFrame) {
+  constexpr char kUrl1[] = "https://www.foo.com/login.html";
+  constexpr char kUrl2[] = "data:text/html,<html></html>";
+
+  NavigateAndCommit(GURL(kUrl1));
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendChild(std::string("child"));
+  child_rfh = content::NavigationSimulator::NavigateAndCommitFromDocument(
+      GURL(kUrl2), child_rfh);
+  ContentAutofillDriver* main_driver =
+      ContentAutofillDriver::GetForRenderFrameHost(main_rfh());
+  ContentAutofillDriver* child_driver =
+      ContentAutofillDriver::GetForRenderFrameHost(child_rfh);
+  ASSERT_TRUE(main_driver);
+  ASSERT_TRUE(child_driver);
+
+  FormData main_form = CreateFormDataForRenderFrameHost(
+      *main_rfh(), {CreateTestFormField("Username", "username", "",
+                                        FormControlType::kInputText),
+                    CreateTestFormField("Password", "password", "",
+                                        FormControlType::kInputPassword)});
+  FormData child_form = CreateFormDataForRenderFrameHost(
+      *child_rfh,
+      {CreateTestFormField("OTP", "OTP", "", FormControlType::kInputText)});
+
+  // Ensure that the child frame is picked up as a child frame of `main_form`.
+  {
+    autofill::FrameTokenWithPredecessor child_frame_information;
+    child_frame_information.token = child_form.host_frame();
+    main_form.set_child_frames({child_frame_information});
+  }
+
+  {
+    autofill::TestAutofillManagerWaiter waiter(
+        main_driver->GetAutofillManager(),
+        {autofill::AutofillManagerEvent::kFormsSeen});
+    main_driver->renderer_events().FormsSeen(/*updated_forms=*/{main_form},
+                                             /*removed_forms=*/{});
+    child_driver->renderer_events().FormsSeen(/*updated_forms=*/{child_form},
+                                              /*removed_forms=*/{});
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/2));
+  }
+
+  // Simulate that the field types have been determined, since server
+  // communication is turned off.
+  using Observer = autofill::AutofillManager::Observer;
+  main_driver->GetAutofillManager().NotifyObservers(
+      &Observer::OnFieldTypesDetermined, main_form.global_id(),
+      Observer::FieldTypeSource::kAutofillServer,
+      /*small_forms_were_parsed=*/false);
+
+  ContentPasswordManagerDriver* main_password_driver =
+      ContentPasswordManagerDriver::GetForRenderFrameHost(main_rfh());
+  password_manager::DriverId main_driver_id = main_password_driver->GetId();
+
+  // Since the child frame is a data URL (opaque origin), predictions should NOT
+  // be propagated for it. Thus, only the main form's predictions are received.
+  EXPECT_THAT(static_cast<const password_manager::PasswordManager*>(
+                  GetClient()->GetPasswordManager())
+                  ->GetServerPredictionsForTesting(),
+              UnorderedElementsAre(Key(testing::Pair(
+                  CalculateFormSignature(main_form), main_driver_id))));
+}
+
+TEST_F(ChromePasswordManagerClientTest,
        GetPasswordManagerDelegateReturnsAutofillManagerForOnlyFrame) {
   NavigateAndCommit(GURL("https://www.foo.com/login.html"));
   auto* driver =
@@ -2268,7 +2334,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   GetClient()->ShowKeyboardReplacingSurface(
@@ -2319,7 +2385,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   GetClient()->ShowKeyboardReplacingSurface(
@@ -2384,7 +2450,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   auto* driver =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
index fe3f689..8f030c2f 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
@@ -846,7 +846,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   // Simulate that the field types have been determined, since server
@@ -962,7 +962,7 @@
                                              /*removed_forms=*/{});
     child_driver->renderer_events().FormsSeen(/*updated_forms=*/{child_form},
                                               /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/2));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/2));
   }
 
   // Simulate that the field types have been determined, since server
@@ -994,6 +994,72 @@
 }
 
 TEST_F(ChromePasswordManagerClientTest,
+       PasswordManagerDoesNotReceiveAutofillPredictionsFromOpaqueOriginFrame) {
+  constexpr char kUrl1[] = "https://www.foo.com/login.html";
+  constexpr char kUrl2[] = "data:text/html,<html></html>";
+
+  NavigateAndCommit(GURL(kUrl1));
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendChild(std::string("child"));
+  child_rfh = content::NavigationSimulator::NavigateAndCommitFromDocument(
+      GURL(kUrl2), child_rfh);
+  ContentAutofillDriver* main_driver =
+      ContentAutofillDriver::GetForRenderFrameHost(main_rfh());
+  ContentAutofillDriver* child_driver =
+      ContentAutofillDriver::GetForRenderFrameHost(child_rfh);
+  ASSERT_TRUE(main_driver);
+  ASSERT_TRUE(child_driver);
+
+  FormData main_form = CreateFormDataForRenderFrameHost(
+      *main_rfh(), {CreateTestFormField("Username", "username", "",
+                                        FormControlType::kInputText),
+                    CreateTestFormField("Password", "password", "",
+                                        FormControlType::kInputPassword)});
+  FormData child_form = CreateFormDataForRenderFrameHost(
+      *child_rfh,
+      {CreateTestFormField("OTP", "OTP", "", FormControlType::kInputText)});
+
+  // Ensure that the child frame is picked up as a child frame of `main_form`.
+  {
+    autofill::FrameTokenWithPredecessor child_frame_information;
+    child_frame_information.token = child_form.host_frame();
+    main_form.set_child_frames({child_frame_information});
+  }
+
+  {
+    autofill::TestAutofillManagerWaiter waiter(
+        main_driver->GetAutofillManager(),
+        {autofill::AutofillManagerEvent::kFormsSeen});
+    main_driver->renderer_events().FormsSeen(/*updated_forms=*/{main_form},
+                                             /*removed_forms=*/{});
+    child_driver->renderer_events().FormsSeen(/*updated_forms=*/{child_form},
+                                              /*removed_forms=*/{});
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/2));
+  }
+
+  // Simulate that the field types have been determined, since server
+  // communication is turned off.
+  using Observer = autofill::AutofillManager::Observer;
+  main_driver->GetAutofillManager().NotifyObservers(
+      &Observer::OnFieldTypesDetermined, main_form.global_id(),
+      Observer::FieldTypeSource::kAutofillServer,
+      /*small_forms_were_parsed=*/false);
+
+  ContentPasswordManagerDriver* main_password_driver =
+      ContentPasswordManagerDriver::GetForRenderFrameHost(main_rfh());
+  password_manager::DriverId main_driver_id = main_password_driver->GetId();
+
+  // Since the child frame is a data URL (opaque origin), predictions should NOT
+  // be propagated for it. Thus, only the main form's predictions are received.
+  EXPECT_THAT(static_cast<const password_manager::PasswordManager*>(
+                  GetClient()->GetPasswordManager())
+                  ->GetServerPredictionsForTesting(),
+              UnorderedElementsAre(Key(testing::Pair(
+                  CalculateFormSignature(main_form), main_driver_id))));
+}
+
+TEST_F(ChromePasswordManagerClientTest,
        GetPasswordManagerDelegateReturnsAutofillManagerForOnlyFrame) {
   NavigateAndCommit(GURL("https://www.foo.com/login.html"));
   auto* driver =
@@ -2268,7 +2334,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   GetClient()->ShowKeyboardReplacingSurface(
@@ -2319,7 +2385,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   GetClient()->ShowKeyboardReplacingSurface(
@@ -2384,7 +2450,7 @@
         {autofill::AutofillManagerEvent::kFormsSeen});
     autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
                                                  /*removed_forms=*/{});
-    ASSERT_TRUE(waiter.Wait(/*num_awaiting_calls=*/1));
+    ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
   }
 
   auto* driver =
Loading diff…

Original Bug Report

reported by vm...@google.com

Credential disclosure to sandboxed iframe via PropagatePredictionsToPasswordManager

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 bypass in Chrome’s password manager allows sandboxed iframes to retrieve saved credentials matching their precursor URL. Because the form URL is stamped with the document precursor URL rather than the opaque committed origin, the browser may fetch and store cleartext credentials in the sandboxed driver’s cache. A subsequent suggestion acceptance allows the sandboxed renderer to access and exfiltrate the victim’s cleartext credentials.

Affected files:

  • chrome/browser/password_manager/chrome_password_manager_client.cc
  • components/password_manager/content/browser/content_password_manager_driver.cc
  • components/autofill/content/browser/content_autofill_driver.cc
  • components/password_manager/core/browser/password_autofill_manager.cc
  • components/password_manager/core/browser/password_form_digest.cc

Estimated timestamp from git blame: 2019-08-30

Root Cause Analysis

In components/autofill/content/browser/content_autofill_driver.cc, the function Lift(ContentAutofillDriver&, FormData) populates form.url() from RenderFrameHost::GetLastCommittedURL()—the document precursor URL—rather than the committed origin:

GURL unstripped_url = rfh.GetLastCommittedURL();
if (unstripped_url.SchemeIs(url::kAboutScheme)) {
  unstripped_url = rfh.GetLastCommittedOrigin().GetURL();
}
form.set_url(StripAuthAndParams(unstripped_url));

For a document loaded inside an <iframe sandbox> or under a CSP sandbox directive, GetLastCommittedURL() returns the precursor URL (e.g., https://victim.com/...), while GetLastCommittedOrigin() returns an opaque origin.

When Autofill server predictions are received, ChromePasswordManagerClient::PropagatePredictionsToPasswordManager routes these predictions to the per-frame ContentPasswordManagerDriver without validating origin access permissions:

// chrome/browser/password_manager/chrome_password_manager_client.cc
auto* driver = ContentPasswordManagerDriver::GetForRenderFrameHost(rfh);
// Missing check: driver->HasValidURL()
password_manager_.ProcessAutofillPredictions(driver, renderer_form, ...);

Unlike other renderer-facing entry points in ContentPasswordManagerDriver which early-return if !HasValidURL(), this propagation path is completely un-gated. As a result, the PasswordFormManager is constructed with the precursor-derived victim.com URL and queries the database for credentials associated with https://victim.com.

Once retrieved, the cleartext credentials are cached inside the sandboxed driver’s PasswordAutofillManager::fill_data_. When the user focuses the field and accepts the suggestion, the password manager dispatches FillPasswordSuggestion over IPC directly to the sandboxed renderer, bypassing the sandboxed process isolation.

Potential Attack Scenario

  1. A victim’s browser has saved credentials for https://victim.com.
  2. The victim visits https://victim.com/u/attacker.html which is served with Content-Security-Policy: sandbox allow-scripts allow-forms (or is embedded on an untrusted page inside a sandboxed iframe pointing to the precursor URL).
  3. The sandboxed iframe contains a simple form structured to trigger an Autofill server prediction (e.g., a username-first login pattern).
  4. The browser process receives the form, stamps its URL as https://victim.com/... during Lift(), and retrieves the corresponding server prediction.
  5. The browser initiates a credentials fetch for the precursor origin, caching the cleartext credentials in the sandboxed frame’s PasswordAutofillManager.
  6. The user clicks the field and selects their username suggestion from the dropdown.
  7. The browser process transmits the cleartext password back to the sandboxed renderer where attacker-controlled JavaScript reads and exfiltrates the credential.

Note: These are potential steps based on manual source code analysis; our automated tooling does not currently have the capability to run code to confirm behavior.

Suggested Remediation

Ensure that ChromePasswordManagerClient::PropagatePredictionsToPasswordManager verifies that the target frame has permission to access the credentials before propagating predictions. Specifically, verify origin-access permissions via driver->HasValidURL() before invoking ProcessAutofillPredictions or ProcessClassificationModelPredictions:

if (!driver->HasValidURL()) {
  continue;
}

Evaluated with Chrome root at commit: 3947e01999a53d4e2382e39736cb79d79c7dffcf


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