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 1ec3f55fd7505..ef16b9f6be9d9 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 @@ -129,6 +129,122 @@ TEST_F(CrossOriginNavigationObserverTest, NavigationToAffiliatedDomain) { NavigateAndCommit(GURL("https://www.bar.com/settings/")); } +// ===================================================================== +// POC: Empty-string eTLD+1 bypass via android:// affiliation facets. +// +// Google's affiliation backend returns android:// facet URIs in the +// equivalence class of any site that publishes an Android app. +// GURL("android://hash@com.example.app") has no host component, so +// GetExtendedTopLevelDomain() returns "". ParseFacets()/ +// OnAffiliationsReceived() insert this "" into affiliated_domains_ +// without filtering. IsSameOrAffiliatedDomain() then treats *any* URL +// whose own eTLD+1 is "" -- IP literals, single-label intranet hosts, +// data:/about:/file: -- as same-domain. The cross-origin abort guard +// is therefore bypassed for the entire non-registrable destination +// class, and the password-change flow will autofill the user's +// plaintext leaked password into a form served from such an origin. +// ===================================================================== + +// Control case: with NO android:// facet in the affiliation results, +// navigating to a raw IP correctly fires the cross-origin callback. +TEST_F(CrossOriginNavigationObserverTest, + Control_NavigationToIPAddressWithoutAndroidFacet) { + base::MockRepeatingClosure on_navigated_to_different_origin; + EXPECT_CALL(affiliation_service(), GetPSLExtensions) + .WillOnce(RunOnceCallback<0>(std::vector())); + affiliations::AffiliatedFacets facets; + facets.emplace_back( + affiliations::FacetURI::FromCanonicalSpec("https://www.foo.com")); + EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding) + .WillOnce(RunOnceCallback<1>(facets, true)); + // Correct behaviour: IP is cross-origin -> callback fires. + EXPECT_CALL(on_navigated_to_different_origin, Run); + + CrossOriginNavigationObserver observer( + web_contents(), &affiliation_service(), + on_navigated_to_different_origin.Get()); + task_environment()->RunUntilIdle(); + + NavigateAndCommit(GURL("http://203.0.113.5/pwd")); +} + +// BUG: presence of an android:// facet (returned by Google's affiliation +// backend for any site with a mobile app) poisons affiliated_domains_ +// with "", and a subsequent navigation to a raw IP literal is silently +// treated as same-domain. The abort callback never fires. +TEST_F(CrossOriginNavigationObserverTest, + Bug_AndroidFacetLetsIPAddressBypassCrossOriginGuard) { + base::MockRepeatingClosure on_navigated_to_different_origin; + EXPECT_CALL(affiliation_service(), GetPSLExtensions) + .WillOnce(RunOnceCallback<0>(std::vector())); + // Realistic equivalence class: web facet + Android app facet. + affiliations::AffiliatedFacets facets; + facets.emplace_back( + affiliations::FacetURI::FromCanonicalSpec("https://www.foo.com")); + facets.emplace_back(affiliations::FacetURI::FromCanonicalSpec( + "android://" + "Y2VydGlmaWNhdGVoYXNoCg@com.foo.android")); + EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding) + .WillOnce(RunOnceCallback<1>(facets, true)); + + // *** VULNERABLE BEHAVIOUR *** + // The callback is NOT invoked even though 203.0.113.5 is an entirely + // unrelated origin. This assertion PASSES on the vulnerable build, + // demonstrating the bypass; on a fixed build it would FAIL. + EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0); + + CrossOriginNavigationObserver observer( + web_contents(), &affiliation_service(), + on_navigated_to_different_origin.Get()); + task_environment()->RunUntilIdle(); + + // The android:// facet maps to "" -> "" is now in affiliated_domains_. + // An IP literal also maps to "" -> contains("") -> treated as + // same-or-affiliated. + EXPECT_TRUE(observer.IsSameOrAffiliatedDomain(GURL("http://203.0.113.5/"))); + EXPECT_TRUE(observer.IsSameOrAffiliatedDomain(GURL("http://10.0.0.1/"))); + EXPECT_TRUE(observer.IsSameOrAffiliatedDomain(GURL("http://intranet/"))); + EXPECT_TRUE(observer.IsSameOrAffiliatedDomain(GURL("about:blank"))); + // A registrable cross-origin domain is still (correctly) rejected. + EXPECT_FALSE(observer.IsSameOrAffiliatedDomain(GURL("https://evil.com/"))); + + // Committed cross-origin navigation to an attacker-controlled IP is + // not aborted -> ChangePasswordFormWaiter will accept a form here and + // FillChangePasswordForm() will type the user's plaintext password + // into it. + NavigateAndCommit(GURL("http://203.0.113.5/pwd")); +} + +// BUG variant: same bypass for single-label intranet hosts (e.g. an +// internal SSO IdP the change-password page redirects to). +TEST_F(CrossOriginNavigationObserverTest, + Bug_AndroidFacetLetsSingleLabelHostBypassCrossOriginGuard) { + base::MockRepeatingClosure on_navigated_to_different_origin; + EXPECT_CALL(affiliation_service(), GetPSLExtensions) + .WillOnce(RunOnceCallback<0>(std::vector())); + affiliations::AffiliatedFacets facets; + facets.emplace_back( + affiliations::FacetURI::FromCanonicalSpec("https://www.foo.com")); + facets.emplace_back(affiliations::FacetURI::FromCanonicalSpec( + "android://" + "Y2VydGlmaWNhdGVoYXNoCg@com.foo.android")); + EXPECT_CALL(affiliation_service(), GetAffiliationsAndBranding) + .WillOnce(RunOnceCallback<1>(facets, true)); + // *** VULNERABLE BEHAVIOUR *** -- callback should fire but doesn't. + EXPECT_CALL(on_navigated_to_different_origin, Run).Times(0); + + CrossOriginNavigationObserver observer( + web_contents(), &affiliation_service(), + on_navigated_to_different_origin.Get()); + task_environment()->RunUntilIdle(); + + NavigateAndCommit(GURL("http://corp-sso/login")); +} + +// ===================================================================== +// End of POC tests. +// ===================================================================== + TEST_F(CrossOriginNavigationObserverTest, DomainIsPartOfPSLExtensionList) { base::MockRepeatingClosure on_navigated_to_different_origin;