diff --git a/ios/chrome/credential_provider_extension/ui/credential_list_mediator_unittest.mm b/ios/chrome/credential_provider_extension/ui/credential_list_mediator_unittest.mm index c4f5206af6969..e7da2ba74da38 100644 --- a/ios/chrome/credential_provider_extension/ui/credential_list_mediator_unittest.mm +++ b/ios/chrome/credential_provider_extension/ui/credential_list_mediator_unittest.mm @@ -526,4 +526,89 @@ TEST_F(CredentialListMediatorTest, FilterPasswordCredentialsNoFalseMatch) { ASSERT_EQ(filteredCredentials.count, 0u); } +// Tests that a credential saved for an apex domain is NOT suggested when the +// requesting host lives under a private registry (Public Suffix List entry) +// that happens to be a subdomain of that apex. Because the CPE never +// PSL-normalizes the requesting host before the hasSuffix: comparison, this +// test currently FAILS, demonstrating the asymmetric-match vulnerability. +// +// Concrete example: "up.railway.app" is on the PSL (private section), so +// eTLD+1("attacker.up.railway.app", INCLUDE_PRIVATE) == "attacker.up.railway.app", +// which is a different registrable domain from "railway.app". The CPE must not +// place the railway.app credential in the Suggested list for that host. +TEST_F(CredentialListMediatorTest, + FilterPasswordCredentialsNoPSLPrivateRegistryFalseMatch) { + // Credential as it would be archived by Chrome after the victim saves a + // password at https://railway.app/: registryControlledDomain is computed in + // the browser via net::registry_controlled_domains::GetDomainAndRegistry( + // url, INCLUDE_PRIVATE_REGISTRIES) == "railway.app". + ArchivableCredential* apexCredential = + [[ArchivableCredential alloc] initWithFavicon:nil + gaia:nil + password:@"victim-apex-password" + rank:1 + recordIdentifier:@"recordIdentifier" + serviceIdentifier:@"https://railway.app/" + serviceName:@"railway.app" + registryControlledDomain:@"railway.app" + username:@"victim@example.com" + note:@"" + lastUsedTime:0]; + + id credentialStore = [[MockCredentialStore alloc] + initWithCredentials:@[ apexCredential ]]; + + // iOS supplies the full page URL for web logins + // (ASCredentialServiceIdentifierTypeURL); HostForServiceIdentifier() + // extracts the bare NSURL.host without any PSL lookup. + ASCredentialServiceIdentifier* attackerTenant = + [[ASCredentialServiceIdentifier alloc] + initWithIdentifier:@"https://attacker.up.railway.app/login" + type:ASCredentialServiceIdentifierTypeURL]; + + CredentialListMediator* mediator = [[CredentialListMediator alloc] + initWithConsumer:nil + UIHandler:nil + credentialStore:credentialStore + serviceIdentifiers:@[ attackerTenant ] + credentialResponseHandler:nil]; + + mediator.allCredentials = [mediator fetchAllCredentials]; + NSArray>* filtered = [mediator filterCredentials]; + + // EXPECTED (correct PSL-aware behaviour): 0 — different registrable domain. + // ACTUAL (current code): 1 — the apex credential is incorrectly Suggested + // because [@"attacker.up.railway.app" hasSuffix:@".railway.app"] == YES. + EXPECT_EQ(filtered.count, 0u) + << "railway.app credential must NOT be Suggested for " + "attacker.up.railway.app (separate registrable domain per PSL)"; + + // Also exercise the fallback branch (registryControlledDomain empty), which + // has the same asymmetry against credential.serviceIdentifier's bare host. + ArchivableCredential* apexCredentialNoRCD = + [[ArchivableCredential alloc] initWithFavicon:nil + gaia:nil + password:@"victim-apex-password" + rank:1 + recordIdentifier:@"recordIdentifier2" + serviceIdentifier:@"https://railway.app/" + serviceName:@"railway.app" + registryControlledDomain:@"" + username:@"victim@example.com" + note:@"" + lastUsedTime:0]; + id credentialStore2 = [[MockCredentialStore alloc] + initWithCredentials:@[ apexCredentialNoRCD ]]; + CredentialListMediator* mediator2 = [[CredentialListMediator alloc] + initWithConsumer:nil + UIHandler:nil + credentialStore:credentialStore2 + serviceIdentifiers:@[ attackerTenant ] + credentialResponseHandler:nil]; + mediator2.allCredentials = [mediator2 fetchAllCredentials]; + NSArray>* filtered2 = [mediator2 filterCredentials]; + EXPECT_EQ(filtered2.count, 0u) + << "fallback branch (bare-host serviceIdentifier) is also vulnerable"; +} + } // namespace credential_provider_extension