CVE-2026-17715
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/password_manager/chrome_password_manager_client.cc |
modified |
Files Changed
chrome/browser/password_manager/chrome_password_manager_client.ccchrome/browser/password_manager/chrome_password_manager_client_unittest.cc
Patch
From 34bdbf97263d24fad52ed15eedea8f328311f78d Mon Sep 17 00:00:00 2001 From: Maria Kazinova <[email protected]> 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 <[email protected]> Commit-Queue: Maria Kazinova <[email protected]> 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 =
Regression Test / PoC
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 =
Original Bug Report
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.cccomponents/password_manager/content/browser/content_password_manager_driver.cccomponents/autofill/content/browser/content_autofill_driver.cccomponents/password_manager/core/browser/password_autofill_manager.cccomponents/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
- A victim’s browser has saved credentials for
https://victim.com. - The victim visits
https://victim.com/u/attacker.htmlwhich is served withContent-Security-Policy: sandbox allow-scripts allow-forms(or is embedded on an untrusted page inside a sandboxed iframe pointing to the precursor URL). - The sandboxed iframe contains a simple form structured to trigger an Autofill server prediction (e.g., a username-first login pattern).
- The browser process receives the form, stamps its URL as
https://victim.com/...duringLift(), and retrieves the corresponding server prediction. - The browser initiates a credentials fetch for the precursor origin, caching the cleartext credentials in the sandboxed frame’s
PasswordAutofillManager. - The user clicks the field and selects their username suggestion from the dropdown.
- 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.