CVE-2026-13818
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc |
modified |
Files Changed
chrome/browser/password_manager/password_change/cross_origin_navigation_observer.ccchrome/browser/password_manager/password_change/cross_origin_navigation_observer.hchrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc
Patch
From b51aa8867df580e729abccbb2c624adcd40fa894 Mon Sep 17 00:00:00 2001 From: Viktor Semeniuk <[email protected]> Date: Fri, 15 May 2026 08:13:56 -0700 Subject: [PATCH] Update CrossOriginNavigationObserver to handle empty eTLD+1 When eTLD+1 can't be computed an empty string is returned. In such cases host should be verified against initial URL. Fixed: 511823182 Change-Id: I5bbf91e5c8a52f5aaa07ee717ad046cef876d2a4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7832200 Reviewed-by: Vasilii Sukhanov <[email protected]> Commit-Queue: Viktor Semeniuk <[email protected]> Cr-Commit-Position: refs/heads/main@{#1631288} --- diff --git a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.cc b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.cc index d012cd2..bdc726ed 100644 --- a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.cc +++ b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.cc @@ -4,8 +4,8 @@ #include "chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h" +#include "base/barrier_closure.h" #include "base/containers/adapters.h" -#include "base/functional/concurrent_closures.h" #include "components/affiliations/core/browser/affiliation_service.h" #include "components/affiliations/core/browser/affiliation_utils.h" #include "components/password_manager/core/browser/password_form.h" @@ -33,27 +33,33 @@ CrossOriginNavigationObserver::CrossOriginNavigationObserver( content::WebContents* web_contents, + const GURL& initial_url, affiliations::AffiliationService* affiliation_service, base::OnceClosure on_cross_origin_navigation_detected) : web_contents_(web_contents), + initial_host_(initial_url.host()), on_cross_origin_navigation_detected_( std::move(on_cross_origin_navigation_detected)) { - base::ConcurrentClosures concurrent; + std::string main_domain = + affiliations::GetExtendedTopLevelDomain(initial_url, {}); + affiliated_domains_.insert(main_domain); + + base::RepeatingClosure barrier = base::BarrierClosure( + 2, base::BindOnce(&CrossOriginNavigationObserver::OnReady, + weak_ptr_factory_.GetWeakPtr())); + affiliation_service->GetPSLExtensions( base::BindOnce(&CrossOriginNavigationObserver::OnPSLExtensionsReceived, - weak_ptr_factory_.GetWeakPtr(), web_contents_->GetURL()) - .Then(concurrent.CreateClosure())); + weak_ptr_factory_.GetWeakPtr(), initial_url) + .Then(barrier)); affiliation_service->GetAffiliationsAndBranding( affiliations::FacetURI::FromPotentiallyInvalidSpec( - web_contents_->GetURL().GetWithEmptyPath().spec()), + initial_url.GetWithEmptyPath().spec()), base::BindOnce(&ParseFacets) .Then(base::BindOnce( &CrossOriginNavigationObserver::OnAffiliationsReceived, weak_ptr_factory_.GetWeakPtr())) - .Then(concurrent.CreateClosure())); - std::move(concurrent) - .Done(base::BindOnce(&CrossOriginNavigationObserver::OnReady, - weak_ptr_factory_.GetWeakPtr())); + .Then(barrier)); } CrossOriginNavigationObserver::~CrossOriginNavigationObserver() = default; @@ -73,8 +79,14 @@ bool CrossOriginNavigationObserver::IsSameOrAffiliatedDomain( const GURL& url) const { - return affiliated_domains_.contains( - affiliations::GetExtendedTopLevelDomain(url, psl_extension_list_)); + std::string domain = + affiliations::GetExtendedTopLevelDomain(url, psl_extension_list_); + // An empty domain should not match anything, unless it's the exact same host + // as the initial URL (e.g. IP addresses where eTLD+1 is empty). + if (domain.empty()) { + return url.host() == initial_host_; + } + return affiliated_domains_.contains(domain); } void CrossOriginNavigationObserver::OnPSLExtensionsReceived( diff --git a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h index 4ec497d..e8f4af11 100644 --- a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h +++ b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h @@ -16,6 +16,7 @@ public: CrossOriginNavigationObserver( content::WebContents* web_contents, + const GURL& initial_url, affiliations::AffiliationService* affiliation_service, base::OnceClosure on_cross_origin_navigation_detected); ~CrossOriginNavigationObserver() override; @@ -34,6 +35,9 @@ const raw_ptr<content::WebContents> web_contents_; + // Host of the initial URL used during initialization. + std::string initial_host_; + // PSL extension list. Necessary for converting `GURL`s into eTLD+1. base::flat_set<std::string> psl_extension_list_; diff --git a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc index 1ec3f55..958b831 100644 --- a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc +++ b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc @@ -4,8 +4,12 @@ #include "chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h" +#include "base/barrier_closure.h" +#include "base/functional/callback_helpers.h" +#include "base/run_loop.h" #include "base/test/gmock_callback_support.h" #include "base/test/mock_callback.h" +#include "base/test/run_until.h" #include "chrome/test/base/chrome_render_view_host_test_harness.h" #include "components/affiliations/core/browser/mock_affiliation_service.h" #include "content/public/test/web_contents_tester.h" @@ -43,33 +47,51 @@ TEST_F(CrossOriginNavigationObserverTest, NoNavigationAfterwards) { base::MockRepeatingClosure on_navigated_to_different_origin; + base::RunLoop run_loop; + auto barrier = base::BarrierClosure(2, run_loop.QuitClosure()); + EXPECT_CALL(affiliation_service(), GetPSLExtensions) - .WillOnce(RunOnceCallback<0>(std::vector<std::string>())); + .WillOnce([&](auto callback) { + std::move(callback).Run(std::vector<std::string>()); + barrier.Run(); + }); EXPECT_CALL( affiliation_service(), GetAffiliationsAndBranding( affiliations::FacetURI::FromCanonicalSpec("https://www.foo.com"), _)) - .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true)); + .WillOnce([&](auto uri, auto callback) { + std::move(callback).Run(affiliations::AffiliatedFacets(), true); + barrier.Run(); + }); EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0); CrossOriginNavigationObserver observer( - web_contents(), &affiliation_service(), + web_contents(), web_contents()->GetURL(), &affiliation_service(), on_navigated_to_different_origin.Get()); - task_environment()->RunUntilIdle(); + run_loop.Run(); } TEST_F(CrossOriginNavigationObserverTest, NavigationInTheSameDomain) { base::MockRepeatingClosure on_navigated_to_different_origin; + base::RunLoop run_loop; + auto barrier = base::BarrierClosure(2, run_loop.QuitClosure()); + EXPECT_CALL(affiliation_service(), GetPSLExtensions) - .WillOnce(RunOnceCallback<0>(std::vector<std::string>())); + .WillOnce([&](auto callback) { + std::move(callback).Run(std::vector<std::string>()); + barrier.Run(); + }); EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding) - .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true)); + .WillOnce([&](auto uri, auto callback) { + std::move(callback).Run(affiliations::AffiliatedFacets(), true); + barrier.Run(); + }); EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0); CrossOriginNavigationObserver observer( - web_contents(), &affiliation_service(), + web_contents(), web_contents()->GetURL(), &affiliation_service(), on_navigated_to_different_origin.Get()); - task_environment()->RunUntilIdle(); + run_loop.Run(); NavigateAndCommit(GURL("https://www.foo.com/settings/")); NavigateAndCommit(GURL("https://www.foo.com/settings/password")); @@ -77,16 +99,25 @@ TEST_F(CrossOriginNavigationObserverTest, NavigationToSubdomain) { base::MockRepeatingClosure on_navigated_to_different_origin; + base::RunLoop run_loop; + auto barrier = base::BarrierClosure(2, run_loop.QuitClosure()); + EXPECT_CALL(affiliation_service(), GetPSLExtensions) - .WillOnce(RunOnceCallback<0>(std::vector<std::string>())); + .WillOnce([&](auto callback) { + std::move(callback).Run(std::vector<std::string>()); + barrier.Run();
Regression Test / PoC
diff --git a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc
index 1ec3f55..958b831 100644
--- a/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc
+++ b/chrome/browser/password_manager/password_change/cross_origin_navigation_observer_unittest.cc
@@ -4,8 +4,12 @@
#include "chrome/browser/password_manager/password_change/cross_origin_navigation_observer.h"
+#include "base/barrier_closure.h"
+#include "base/functional/callback_helpers.h"
+#include "base/run_loop.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/mock_callback.h"
+#include "base/test/run_until.h"
#include "chrome/test/base/chrome_render_view_host_test_harness.h"
#include "components/affiliations/core/browser/mock_affiliation_service.h"
#include "content/public/test/web_contents_tester.h"
@@ -43,33 +47,51 @@
TEST_F(CrossOriginNavigationObserverTest, NoNavigationAfterwards) {
base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
+
EXPECT_CALL(affiliation_service(), GetPSLExtensions)
- .WillOnce(RunOnceCallback<0>(std::vector<std::string>()));
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>());
+ barrier.Run();
+ });
EXPECT_CALL(
affiliation_service(),
GetAffiliationsAndBranding(
affiliations::FacetURI::FromCanonicalSpec("https://www.foo.com"), _))
- .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true));
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(affiliations::AffiliatedFacets(), true);
+ barrier.Run();
+ });
EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0);
CrossOriginNavigationObserver observer(
- web_contents(), &affiliation_service(),
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
on_navigated_to_different_origin.Get());
- task_environment()->RunUntilIdle();
+ run_loop.Run();
}
TEST_F(CrossOriginNavigationObserverTest, NavigationInTheSameDomain) {
base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
+
EXPECT_CALL(affiliation_service(), GetPSLExtensions)
- .WillOnce(RunOnceCallback<0>(std::vector<std::string>()));
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>());
+ barrier.Run();
+ });
EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding)
- .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true));
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(affiliations::AffiliatedFacets(), true);
+ barrier.Run();
+ });
EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0);
CrossOriginNavigationObserver observer(
- web_contents(), &affiliation_service(),
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
on_navigated_to_different_origin.Get());
- task_environment()->RunUntilIdle();
+ run_loop.Run();
NavigateAndCommit(GURL("https://www.foo.com/settings/"));
NavigateAndCommit(GURL("https://www.foo.com/settings/password"));
@@ -77,16 +99,25 @@
TEST_F(CrossOriginNavigationObserverTest, NavigationToSubdomain) {
base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
+
EXPECT_CALL(affiliation_service(), GetPSLExtensions)
- .WillOnce(RunOnceCallback<0>(std::vector<std::string>()));
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>());
+ barrier.Run();
+ });
EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding)
- .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true));
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(affiliations::AffiliatedFacets(), true);
+ barrier.Run();
+ });
EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0);
CrossOriginNavigationObserver observer(
- web_contents(), &affiliation_service(),
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
on_navigated_to_different_origin.Get());
- task_environment()->RunUntilIdle();
+ run_loop.Run();
NavigateAndCommit(GURL("https://account.foo.com/settings/"));
NavigateAndCommit(GURL("https://account.foo.com/settings/password"));
@@ -94,54 +125,105 @@
TEST_F(CrossOriginNavigationObserverTest, NavigationToDifferentDomain) {
base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
+
EXPECT_CALL(affiliation_service(), GetPSLExtensions)
- .WillOnce(RunOnceCallback<0>(std::vector<std::string>()));
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>());
+ barrier.Run();
+ });
EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding)
- .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true));
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(affiliations::AffiliatedFacets(), true);
+ barrier.Run();
+ });
EXPECT_CALL(on_navigated_to_different_origin, Run);
CrossOriginNavigationObserver observer(
- web_contents(), &affiliation_service(),
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
on_navigated_to_different_origin.Get());
- task_environment()->RunUntilIdle();
+ run_loop.Run();
NavigateAndCommit(GURL("https://www.bar.com/settings/"));
}
TEST_F(CrossOriginNavigationObserverTest, NavigationToAffiliatedDomain) {
base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
+
EXPECT_CALL(affiliation_service(), GetPSLExtensions)
- .WillOnce(RunOnceCallback<0>(std::vector<std::string>()));
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>());
+ barrier.Run();
+ });
affiliations::AffiliatedFacets facets;
facets.emplace_back(
affiliations::FacetURI::FromCanonicalSpec("https://www.foo.com"));
facets.emplace_back(
affiliations::FacetURI::FromCanonicalSpec("https://www.bar.com"));
EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding)
- .WillOnce(RunOnceCallback<1>(facets, true));
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(facets, true);
+ barrier.Run();
+ });
EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0);
CrossOriginNavigationObserver observer(
- web_contents(), &affiliation_service(),
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
on_navigated_to_different_origin.Get());
- task_environment()->RunUntilIdle();
+ run_loop.Run();
NavigateAndCommit(GURL("https://www.bar.com/settings/"));
}
TEST_F(CrossOriginNavigationObserverTest, DomainIsPartOfPSLExtensionList) {
base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
EXPECT_CALL(affiliation_service(), GetPSLExtensions)
- .WillOnce(RunOnceCallback<0>(std::vector<std::string>{"foo.com"}));
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>{"foo.com"});
+ barrier.Run();
+ });
EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding)
- .WillOnce(RunOnceCallback<1>(affiliations::AffiliatedFacets(), true));
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(affiliations::AffiliatedFacets(), true);
+ barrier.Run();
+ });
EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0);
CrossOriginNavigationObserver observer(
- web_contents(), &affiliation_service(),
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
on_navigated_to_different_origin.Get());
- task_environment()->RunUntilIdle();
+ run_loop.Run();
NavigateAndCommit(GURL("https://www.foo.com/settings/"));
}
+
+TEST_F(CrossOriginNavigationObserverTest, NavigationToInvalidUrl) {
+ base::MockRepeatingClosure on_navigated_to_different_origin;
+ base::RunLoop run_loop;
+ auto barrier = base::BarrierClosure(2, run_loop.QuitClosure());
+
+ EXPECT_CALL(affiliation_service(), GetPSLExtensions)
+ .WillOnce([&](auto callback) {
+ std::move(callback).Run(std::vector<std::string>());
+ barrier.Run();
+ });
+ EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding)
+ .WillOnce([&](auto uri, auto callback) {
+ std::move(callback).Run(affiliations::AffiliatedFacets(), true);
+ barrier.Run();
+ });
+ EXPECT_CALL(on_navigated_to_different_origin, Run);
+
+ CrossOriginNavigationObserver observer(
+ web_contents(), web_contents()->GetURL(), &affiliation_service(),
+ on_navigated_to_different_origin.Get());
+ run_loop.Run();
+
+ NavigateAndCommit(GURL("about:blank"));
+}
diff --git a/chrome/browser/password_manager/password_change_browsertest.cc b/chrome/browser/password_manager/password_change_browsertest.cc
index ebb3ffa..12a9fc9 100644
--- a/chrome/browser/password_manager/password_change_browsertest.cc
+++ b/chrome/browser/password_manager/password_change_browsertest.cc
@@ -168,7 +168,7 @@
form.password_value = password;
if (!change_pwd_path.empty()) {
form.change_password_url =
- embedded_test_server()->GetURL(change_pwd_path);
+ embedded_test_server()->GetURL(url.host(), change_pwd_path);
} else {
form.change_password_url = url.Resolve("/change-password");
}
@@ -554,8 +554,8 @@
// Add an existing password for this site.
ASSERT_TRUE(content::NavigateToURL(
- WebContents(),
- embedded_test_server()->GetURL("/password/simple_password.html")));
+ WebContents(), embedded_test_server()->GetURL(
+ kMainHost, "/password/simple_password.html")));
password_manager::PasswordStoreInterface* password_store =
GetDefaultPasswordStore(browser()->profile());
password_manager::PasswordForm form = CreatePasswordForm(
@@ -738,6 +738,7 @@
IN_PROC_BROWSER_TEST_F(PasswordChangeBrowserTest,
ViewDetailsFromToastAfterPageNavigation) {
SetPrivacyNoticeAcceptedPref();
+
password_change_service()->OfferPasswordChangeUi(
CreatePasswordForm(WebContents()->GetLastCommittedURL(), u"test",
u"pa$$word",
@@ -764,6 +765,11 @@
EXPECT_TRUE(toast->action_button());
EXPECT_TRUE(toast->action_button()->GetVisible());
+ // Navigate away to a different host.
+ ASSERT_TRUE(content::NavigateToURL(
+ WebContents(), https_test_server().GetURL(
+ kDifferentHost, "/password/simple_password.html")));
+
// Click action button, this should open Password Management.
views::test::ButtonTestApi clicker(toast->action_button());
delegate = nullptr;
@@ -785,8 +791,8 @@
IN_PROC_BROWSER_TEST_F(PasswordChangeBrowserTest, ViewPasswordBubbleFromToast) {
ASSERT_TRUE(content::NavigateToURL(
- WebContents(),
- embedded_test_server()->GetURL("/password/simple_password.html")));
+ WebContents(), embedded_test_server()->GetURL(
+ kMainHost, "/password/simple_password.html")));
SetPrivacyNoticeAcceptedPref();
@@ -1146,7 +1152,7 @@
browser()->tab_strip_model()->GetWebContentsAt(1);
ASSERT_EQ(change_password_contents->GetVisibleURL(),
embedded_test_server()->GetURL(
- "/password/update_form_empty_fields.html"));
+ kMainHost, "/password/update_form_empty_fields.html"));
}
IN_PROC_BROWSER_TEST_F(PasswordChangeBrowserTest,
@@ -1345,8 +1351,8 @@
UserInterventionAfterSubmission_TaskWasNotTakenOver) {
SetPrivacyNoticeAcceptedPref();
ASSERT_TRUE(content::NavigateToURL(
- WebContents(),
- embedded_test_server()->GetURL("/password/simple_password.html")));
+ WebContents(), embedded_test_server()->GetURL(
+ kMainHost, "/password/simple_password.html")));
password_manager::PasswordStoreInterface* password_store =
GetDefaultPasswordStore(browser()->profile());
Original Bug Report
Potential credential leak to IP origins via empty eTLD+1 bypass 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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The CrossOriginNavigationObserver used in the Automated Password Change flow fails to filter empty strings from its affiliated domains set when processing Android app affiliations. This allows the origin guard to be bypassed if the page navigates to a non-registrable origin like an IP address. Consequently, the user’s plaintext credentials can be autofilled into a form on an attacker-controlled IP origin.
Affected files:
chrome/browser/password_manager/password_change/cross_origin_navigation_observer.ccchrome/browser/password_manager/password_change_delegate_impl.ccchrome/browser/password_manager/password_change/change_password_form_waiter.ccchrome/browser/password_manager/password_change/change_password_form_filling_submission_helper.cc
Estimated timestamp from git blame: 2025-09-30
Description
The CrossOriginNavigationObserver acts as the primary origin guard for the Automated Password Change (APC) flow. It is designed to abort the flow if the executor tab navigates away from the legitimate password change site or its affiliated domains. However, a logic flaw in how eTLD+1s are extracted and stored allows this guard to be bypassed, potentially leading to the disclosure of plaintext credentials.
When the observer initializes, it requests the site’s affiliations. The ParseFacets function in cross_origin_navigation_observer.cc extracts the eTLD+1 for each affiliation using affiliations::GetExtendedTopLevelDomain().
If the site has an associated Android app (e.g., android://[email protected]), GetExtendedTopLevelDomain() delegates to net::registry_controlled_domains::GetDomainAndRegistry(). Because android:// is an opaque, non-standard scheme, its host is empty. GetDomainAndRegistry explicitly returns an empty string "" for empty hosts. These empty strings are blindly inserted into the affiliated_domains_ set, poisoning it.
Later, if the tab navigates, CrossOriginNavigationObserver::NavigationEntryCommitted() calls IsSameOrAffiliatedDomain() to verify the new URL. If the new URL is an IP address, GetExtendedTopLevelDomain() again returns an empty string "" (as GetDomainAndRegistry explicitly returns an empty string for IP literals via url::HostIsIPAddress).
Because the affiliated_domains_ set contains "", the observer incorrectly concludes the IP address is an affiliated domain and allows the flow to continue.
Potential Exploitation Steps
Note: These are suggested steps based on static analysis; our tooling agent does not run active code execution to build a live proof-of-concept.
- A user initiates the APC flow for a legitimate service that has an Android app affiliation (e.g.,
example.com). - An attacker induces a navigation in the executor tab to an IP address they control (e.g.,
http://1.2.3.4). This could be achieved via an unpatched Open Redirect onexample.com, or by manipulating the page DOM so that Chrome’s APC mechanisms click a malicious link. - The
CrossOriginNavigationObserverevaluates the navigation, computes the IP address’s eTLD+1 as"", finds""in theaffiliated_domains_set, and fails to abort the flow. - The attacker’s server at
1.2.3.4serves an HTML page containing a structural mock of a password change form. - The still-active
ChangePasswordFormWaiterdetects the mock form. It does not perform secondary origin validation, relying entirely on the bypassedCrossOriginNavigationObserver. PasswordChangeDelegateImpl::OnPasswordChangeFormFound()receives the form and passes the user’s plaintext password toChangePasswordFormFillingSubmissionHelper::FillChangePasswordForm().- The password is sent via Mojo IPC to the attacker’s renderer process and autofilled into the mock form.
- Malicious JavaScript on the attacker’s page reads the
.valueof the autofilled input element and exfiltrates the password.
Suggested Fix
- Filter Empty Strings: Update
ParseFacetsand/orCrossOriginNavigationObserver::OnAffiliationsReceived()to explicitly ignore and drop empty strings. - Harden the Check: Update
CrossOriginNavigationObserver::IsSameOrAffiliatedDomain()to immediately returnfalseif the computedmain_domainis empty, preventing any accidental matches against an empty string in the set.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.