CVE-2026-13847
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/build/config.gni |
modified | |
source_setios/chrome/credential_provider_extension/ui/BUILD.gn |
modified |
Files Changed
ios/build/config.gniios/chrome/credential_provider_extension/BUILD.gnios/chrome/credential_provider_extension/passkey_request_details.mmios/chrome/credential_provider_extension/passkey_request_details_unittest.mmios/chrome/credential_provider_extension/ui/BUILD.gn
Patch
From 67a8f29c4ffb37f0be20519e89b373b17b5792f9 Mon Sep 17 00:00:00 2001 From: Alexis Hétu <[email protected]> Date: Thu, 04 Jun 2026 07:28:48 -0700 Subject: [PATCH] [iOS] Secure CPE domain suggestions against private registry hijacking This CL secures host matchmaking inside the iOS Credential Provider Extension (CPE) to prevent subdomain-spoofing attacks on shared registries (e.g., railway.app). By leveraging the lightweight compiled TLD database under net::registry_controlled_domains, CPE now robustly verifies and symmetrically matches eTLD+1 registrable domains. This secure validation replaces raw suffix checks to safely govern both password suggestion suggestions and automatic passkey upgrades. Bug: 517073397 Change-Id: Ie9801e39732db474157bd033039ef74fc0b1ba65 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7881667 Reviewed-by: Tommy Martino <[email protected]> Commit-Queue: Alexis Hétu <[email protected]> Reviewed-by: Sylvain Defresne <[email protected]> Cr-Commit-Position: refs/heads/main@{#1641632} --- diff --git a/ios/build/config.gni b/ios/build/config.gni index 0a6770a..c693b55 100644 --- a/ios/build/config.gni +++ b/ios/build/config.gni @@ -29,8 +29,18 @@ ios_assert_no_deps = _ios_transient_bad_dependencies if (!use_blink) { ios_assert_no_deps += _ios_conceptually_bad_dependencies + + # Disallow all "//net" dependencies, except "//net/base". ios_extension_assert_no_deps += [ - "//net/*", - "//url/*", + "//net", + "//net/android/*", + "//net/cert/*", + "//net/data/*", + "//net/dns/*", + "//net/http/*", + "//net/log/*", + "//net/third_party/*", + "//net/tools/*", + "//net/traffic_annotation/*", ] } diff --git a/ios/chrome/credential_provider_extension/BUILD.gn b/ios/chrome/credential_provider_extension/BUILD.gn index abe54a2..41e5e34 100644 --- a/ios/chrome/credential_provider_extension/BUILD.gn +++ b/ios/chrome/credential_provider_extension/BUILD.gn @@ -204,6 +204,7 @@ "//components/webauthn/ios:passkey_types", "//ios/chrome/common/credential_provider:ui", "//ios/chrome/credential_provider_extension/ui:feature_flags", + "//ios/chrome/credential_provider_extension/ui:net_util", ] frameworks = [ "AuthenticationServices.framework", diff --git a/ios/chrome/credential_provider_extension/passkey_request_details.mm b/ios/chrome/credential_provider_extension/passkey_request_details.mm index fb6ddfa9..3b63395 100644 --- a/ios/chrome/credential_provider_extension/passkey_request_details.mm +++ b/ios/chrome/credential_provider_extension/passkey_request_details.mm @@ -14,6 +14,7 @@ #import "ios/chrome/credential_provider_extension/passkey_util.h" #import "ios/chrome/credential_provider_extension/passkey_util_swift.h" #import "ios/chrome/credential_provider_extension/ui/feature_flags.h" +#import "ios/chrome/credential_provider_extension/ui/net_util.h" namespace { // The maximum time elapsed since a password was used to consider it for a @@ -231,11 +232,8 @@ NSUInteger credentialIndex = [credentials indexOfObjectPassingTest:^BOOL(id<Credential> credential, NSUInteger idx, BOOL* stop) { - NSString* domainSuffix = [NSString - stringWithFormat:@".%@", credential.registryControlledDomain]; - BOOL matchingDomain = - [rpID isEqualToString:credential.registryControlledDomain] || - [rpID hasSuffix:domainSuffix]; + BOOL matchingDomain = credential_provider_extension::SecureHostsMatch( + rpID, credential.registryControlledDomain); base::TimeDelta timeSinceLastUse = now - base::Time::FromDeltaSinceWindowsEpoch( diff --git a/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm b/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm index 5c5e465..df6d83cf 100644 --- a/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm +++ b/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm @@ -115,6 +115,31 @@ excludedCredentials:nil]; EXPECT_TRUE([details hasMatchingPassword:credentials]); + // Private Registry boundary and hijack checks. + id<Credential> credentialRailway = TestPasswordCredential( + user1, @"https://railway.app/", @"railway.app", recentTime); + NSArray<id<Credential>>* credentialsWithRailway = + [credentials arrayByAddingObject:credentialRailway]; + + // Standard subdomain should match. + details = [[PasskeyRequestDetails alloc] initWithURL:@"www.login.railway.app" + username:user1 + excludedCredentials:nil]; + EXPECT_TRUE([details hasMatchingPassword:credentialsWithRailway]); + + // Rogue subdomain crossing private suffix boundary should NOT match! + details = + [[PasskeyRequestDetails alloc] initWithURL:@"attacker.up.railway.app" + username:user1 + excludedCredentials:nil]; + EXPECT_FALSE([details hasMatchingPassword:credentialsWithRailway]); + + // False suffix match should NOT match! + details = [[PasskeyRequestDetails alloc] initWithURL:@"evil-railway.app" + username:user1 + excludedCredentials:nil]; + EXPECT_FALSE([details hasMatchingPassword:credentialsWithRailway]); + // Empty credentials list. EXPECT_FALSE([details hasMatchingPassword:@[]]); @@ -231,21 +256,21 @@ // Recent credential should match. PasskeyRequestDetails* detailsValid = - [[PasskeyRequestDetails alloc] initWithURL:url1 + [[PasskeyRequestDetails alloc] initWithURL:domain1 username:user1 excludedCredentials:nil]; EXPECT_TRUE([detailsValid hasMatchingPassword:@[ validCred ]]); // Expired credential should not match. PasskeyRequestDetails* detailsExpired = - [[PasskeyRequestDetails alloc] initWithURL:url2 + [[PasskeyRequestDetails alloc] initWithURL:domain2 username:user2 excludedCredentials:nil]; EXPECT_FALSE([detailsExpired hasMatchingPassword:@[ expiredCred ]]); // Never used credential should not match. PasskeyRequestDetails* detailsNeverUsed = - [[PasskeyRequestDetails alloc] initWithURL:url3 + [[PasskeyRequestDetails alloc] initWithURL:domain3 username:user3 excludedCredentials:nil]; EXPECT_FALSE([detailsNeverUsed hasMatchingPassword:@[ neverUsedCred ]]); diff --git a/ios/chrome/credential_provider_extension/ui/BUILD.gn b/ios/chrome/credential_provider_extension/ui/BUILD.gn index ff1fcaf4..d16334dc 100644 --- a/ios/chrome/credential_provider_extension/ui/BUILD.gn +++ b/ios/chrome/credential_provider_extension/ui/BUILD.gn @@ -2,6 +2,18 @@ # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. +source_set("net_util") { + sources = [ + "net_util.h", + "net_util.mm", + ] + deps = [ + "//base", + "//net/base:registry_controlled_domains", + ] + frameworks = [ "Foundation.framework" ] +} + source_set("ui") { sources = [ "consent_coordinator.h", @@ -58,6 +70,7 @@ ":credential_list_ui_handler", ":credential_response_handler", ":feature_flags", + ":net_util", ":utils", "//base", "//components/password_manager/core/browser/generation:core", @@ -180,6 +193,7 @@ "credential_list_mediator+Testing.h", "credential_list_mediator_unittest.mm", "multi_profile_passkey_creation_view_controller_unittest.mm", + "net_util_unittest.mm", "new_password_coordinator_unittest.mm", "new_password_mediator_unittest.mm", "passkey_error_alert_view_controller_unittest.mm", @@ -190,6 +204,7 @@ ":mock_credential_list_consumer", ":mock_credential_list_ui_handler", ":mock_credential_response_handler", + ":net_util", ":ui", "//base", "//base/test:test_support", @@ -206,6 +221,7 @@ "//ios/chrome/credential_provider_extension:password_util", "//ios/chrome/credential_provider_extension:reauthentication_handler", "//ios/chrome/credential_provider_extension:system_strings", + "//net/base:registry_controlled_domains", "//testing/gtest",
Regression Test / PoC
diff --git a/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm b/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm
index 5c5e465..df6d83cf 100644
--- a/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm
+++ b/ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm
@@ -115,6 +115,31 @@
excludedCredentials:nil];
EXPECT_TRUE([details hasMatchingPassword:credentials]);
+ // Private Registry boundary and hijack checks.
+ id<Credential> credentialRailway = TestPasswordCredential(
+ user1, @"https://railway.app/", @"railway.app", recentTime);
+ NSArray<id<Credential>>* credentialsWithRailway =
+ [credentials arrayByAddingObject:credentialRailway];
+
+ // Standard subdomain should match.
+ details = [[PasskeyRequestDetails alloc] initWithURL:@"www.login.railway.app"
+ username:user1
+ excludedCredentials:nil];
+ EXPECT_TRUE([details hasMatchingPassword:credentialsWithRailway]);
+
+ // Rogue subdomain crossing private suffix boundary should NOT match!
+ details =
+ [[PasskeyRequestDetails alloc] initWithURL:@"attacker.up.railway.app"
+ username:user1
+ excludedCredentials:nil];
+ EXPECT_FALSE([details hasMatchingPassword:credentialsWithRailway]);
+
+ // False suffix match should NOT match!
+ details = [[PasskeyRequestDetails alloc] initWithURL:@"evil-railway.app"
+ username:user1
+ excludedCredentials:nil];
+ EXPECT_FALSE([details hasMatchingPassword:credentialsWithRailway]);
+
// Empty credentials list.
EXPECT_FALSE([details hasMatchingPassword:@[]]);
@@ -231,21 +256,21 @@
// Recent credential should match.
PasskeyRequestDetails* detailsValid =
- [[PasskeyRequestDetails alloc] initWithURL:url1
+ [[PasskeyRequestDetails alloc] initWithURL:domain1
username:user1
excludedCredentials:nil];
EXPECT_TRUE([detailsValid hasMatchingPassword:@[ validCred ]]);
// Expired credential should not match.
PasskeyRequestDetails* detailsExpired =
- [[PasskeyRequestDetails alloc] initWithURL:url2
+ [[PasskeyRequestDetails alloc] initWithURL:domain2
username:user2
excludedCredentials:nil];
EXPECT_FALSE([detailsExpired hasMatchingPassword:@[ expiredCred ]]);
// Never used credential should not match.
PasskeyRequestDetails* detailsNeverUsed =
- [[PasskeyRequestDetails alloc] initWithURL:url3
+ [[PasskeyRequestDetails alloc] initWithURL:domain3
username:user3
excludedCredentials:nil];
EXPECT_FALSE([detailsNeverUsed hasMatchingPassword:@[ neverUsedCred ]]);
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 6d4947c..7db999e 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
@@ -567,4 +567,149 @@
EXPECT_EQ([mediator filterCredentials].count, 0u);
}
+// Tests that password credential matches registry controlled domain correctly.
+TEST_F(CredentialListMediatorTest,
+ PasswordCredentialMatchesRegistryControlledDomain) {
+ ArchivableCredential* credential =
+ [[ArchivableCredential alloc] initWithFavicon:nil
+ gaia:nil
+ password:@"qwerty123"
+ rank:1
+ recordIdentifier:@"recordIdentifier"
+ serviceIdentifier:@"https://railway.app/"
+ serviceName:@"railway.app"
+ registryControlledDomain:@"railway.app"
+ username:@"username_value"
+ note:@"note"
+ lastUsedTime:0];
+
+ CredentialListMediator* credentialListMediator =
+ [[CredentialListMediator alloc] initWithConsumer:nil
+ UIHandler:nil
+ credentialStore:nil
+ serviceIdentifiers:nil
+ credentialResponseHandler:nil];
+
+ // Exact match.
+ EXPECT_TRUE([credentialListMediator passwordCredential:credential
+ matchesRegistryControlledDomain:@"railway.app"]);
+
+ // Subdomain match.
+ EXPECT_TRUE([credentialListMediator passwordCredential:credential
+ matchesRegistryControlledDomain:@"login.railway.app"]);
+
+ // Attacker website (e.g. attacker app hosted on private public suffix).
+ // The test should expect the attacker website to fail to match.
+ EXPECT_FALSE([credentialListMediator
+ passwordCredential:credential
+ matchesRegistryControlledDomain:@"attacker.up.railway.app"]);
+
+ // False matches.
+ EXPECT_FALSE([credentialListMediator passwordCredential:credential
+ matchesRegistryControlledDomain:@"evil-railway.app"]);
+ EXPECT_FALSE([credentialListMediator
+ passwordCredential:credential
+ matchesRegistryControlledDomain:@"railway.app.evil.com"]);
+ EXPECT_FALSE([credentialListMediator passwordCredential:credential
+ matchesRegistryControlledDomain:@"railway.app.evil"]);
+ EXPECT_FALSE([credentialListMediator passwordCredential:credential
+ matchesRegistryControlledDomain:@"evil.com"]);
+
+ // Empty registryControlledDomain should not match anything.
+ ArchivableCredential* credentialWithEmptyDomain =
+ [[ArchivableCredential alloc] initWithFavicon:nil
+ gaia:nil
+ password:@"qwerty123"
+ rank:1
+ recordIdentifier:@"recordIdentifier"
+ serviceIdentifier:@"https://railway.app/"
+ serviceName:@"railway.app"
+ registryControlledDomain:@""
+ username:@"username_value"
+ note:@"note"
+ lastUsedTime:0];
+ EXPECT_FALSE([credentialListMediator
+ passwordCredential:credentialWithEmptyDomain
+ matchesRegistryControlledDomain:@"railway.app"]);
+
+ // Nil registryControlledDomain should not match anything.
+ ArchivableCredential* credentialWithNilDomain =
+ [[ArchivableCredential alloc] initWithFavicon:nil
+ gaia:nil
+ password:@"qwerty123"
+ rank:1
+ recordIdentifier:@"recordIdentifier"
+ serviceIdentifier:@"https://railway.app/"
+ serviceName:@"railway.app"
+ registryControlledDomain:nil
+ username:@"username_value"
+ note:@"note"
+ lastUsedTime:0];
+ EXPECT_FALSE([credentialListMediator
+ passwordCredential:credentialWithNilDomain
+ matchesRegistryControlledDomain:@"railway.app"]);
+}
+
+// Tests that fallback password credential matching (when
+// registryControlledDomain is empty) matches domain subdomains correctly but
+// rejects public suffix / private registry subdomains.
+TEST_F(CredentialListMediatorTest,
+ PasswordCredentialMatchesFallbackServiceIdentifiers) {
+ ArchivableCredential* credential =
+ [[ArchivableCredential alloc] initWithFavicon:nil
+ gaia:nil
+ password:@"qwerty123"
+ rank:1
+ recordIdentifier:@"recordIdentifier"
+ serviceIdentifier:@"https://railway.app/"
+ serviceName:@"railway.app"
+ registryControlledDomain:@""
+ username:@"username_value"
+ note:@"note"
+ lastUsedTime:0];
+
+ CredentialListMediator* credentialListMediator =
+ [[CredentialListMediator alloc] initWithConsumer:nil
+ UIHandler:nil
+ credentialStore:nil
+ serviceIdentifiers:nil
+ credentialResponseHandler:nil];
+
+ // Exact matches.
+ ASCredentialServiceIdentifier* serviceIdentifierExact =
+ [[ASCredentialServiceIdentifier alloc]
+ initWithIdentifier:@"railway.app"
+ type:ASCredentialServiceIdentifierTypeDomain];
+ EXPECT_TRUE([credentialListMediator
+ passwordCredential:credential
+ matchesServiceIdentifiers:@[ serviceIdentifierExact ]]);
+
+ // Subdomain matches.
+ ASCredentialServiceIdentifier* serviceIdentifierSubdomain =
+ [[ASCredentialServiceIdentifier alloc]
+ initWithIdentifier:@"login.railway.app"
+ type:ASCredentialServiceIdentifierTypeDomain];
+ EXPECT_TRUE([credentialListMediator
+ passwordCredential:credential
+ matchesServiceIdentifiers:@[ serviceIdentifierSubdomain ]]);
+
+ // Attacker matches (should NOT match!).
+ ASCredentialServiceIdentifier* serviceIdentifierAttacker =
+ [[ASCredentialServiceIdentifier alloc]
+ initWithIdentifier:@"attacker.up.railway.app"
+ type:ASCredentialServiceIdentifierTypeDomain];
+ EXPECT_FALSE([credentialListMediator
+ passwordCredential:credential
+ matchesServiceIdentifiers:@[ serviceIdentifierAttacker ]]);
+
+ // False matches.
+ ASCredentialServiceIdentifier* serviceIdentifierEvilSuffix =
+ [[ASCredentialServiceIdentifier alloc]
+ initWithIdentifier:@"railway.app.evil.com"
+ type:ASCredentialServiceIdentifierTypeDomain];
+ EXPECT_FALSE([credentialListMediator
+ passwordCredential:credential
+ matchesServiceIdentifiers:@[ serviceIdentifierEvilSuffix ]]);
+}
+
} // namespace credential_provider_extension
diff --git a/ios/chrome/credential_provider_extension/ui/net_util_unittest.mm b/ios/chrome/credential_provider_extension/ui/net_util_unittest.mm
new file mode 100644
index 0000000..ce2d736
--- /dev/null
+++ b/ios/chrome/credential_provider_extension/ui/net_util_unittest.mm
@@ -0,0 +1,60 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/credential_provider_extension/ui/net_util.h"
+
+#import "net/base/registry_controlled_domains/registry_controlled_domain.h"
+#import "testing/gtest_mac.h"
+#import "testing/platform_test.h"
+
+namespace credential_provider_extension {
+
+using NetUtilTest = PlatformTest;
+
+TEST_F(NetUtilTest, GetDomainAndRegistry) {
+ auto GetDomainAndRegistry = [](std::string_view host) {
+ return net::registry_controlled_domains::GetDomainAndRegistry(
+ host, net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
+ };
+
+ // ICANN Registry TLD matches.
+ EXPECT_EQ(GetDomainAndRegistry("example.com"), "example.com");
+ EXPECT_EQ(GetDomainAndRegistry("login.example.com"), "example.com");
+ EXPECT_EQ(GetDomainAndRegistry("auth.login.example.com"), "example.com");
+ EXPECT_EQ(GetDomainAndRegistry("example.co.uk"), "example.co.uk");
+ EXPECT_EQ(GetDomainAndRegistry("sub.example.co.uk"), "example.co.uk");
+
+ // Private Registry Suffix matches.
+ EXPECT_EQ(GetDomainAndRegistry("railway.app"), "railway.app");
+ EXPECT_EQ(GetDomainAndRegistry("login.railway.app"), "railway.app");
+ EXPECT_EQ(GetDomainAndRegistry("attacker.up.railway.app"),
+ "attacker.up.railway.app");
+
+ // Unknown Registry Suffix fallback rules.
+ EXPECT_EQ(GetDomainAndRegistry("foo.bar.invalid"), "bar.invalid");
+ EXPECT_EQ(GetDomainAndRegistry("invalid"), "");
+
+ // Flat and Invalid Registry values.
+ EXPECT_EQ(GetDomainAndRegistry("com"), "");
+ EXPECT_EQ(GetDomainAndRegistry("co.uk"), "");
+ EXPECT_EQ(GetDomainAndRegistry("up.railway.app"), "");
+ EXPECT_EQ(GetDomainAndRegistry("192.168.1.1"), "");
+ EXPECT_EQ(GetDomainAndRegistry("2001:db8::1"), "");
+ EXPECT_EQ(GetDomainAndRegistry("[::1]"), "");
+ EXPECT_EQ(GetDomainAndRegistry(""), "");
+}
+
+TEST_F(NetUtilTest, SecureHostsMatch) {
+ // Standard matching subdomains.
+ EXPECT_TRUE(SecureHostsMatch(@"login.railway.app", @"railway.app"));
+ EXPECT_TRUE(SecureHostsMatch(@"railway.app", @"login.railway.app"));
+ EXPECT_TRUE(SecureHostsMatch(@"auth.login.example.com", @"example.com"));
+
+ // Suffix Match boundary hijacking checks (should REJECT!).
+ EXPECT_FALSE(SecureHostsMatch(@"attacker.up.railway.app", @"railway.app"));
+ EXPECT_FALSE(SecureHostsMatch(@"evil-railway.app", @"railway.app"));
+ EXPECT_FALSE(SecureHostsMatch(@"railway.app.evil.com", @"railway.app"));
+}
+
+} // namespace credential_provider_extension
Original Bug Report
Potential cross-origin credential disclosure in iOS Credential Provider Extension via PSL mismatch
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: The iOS Credential Provider Extension (CPE) asymmetrically matches the requesting host against a saved credential’s pre-computed eTLD+1 registry-controlled domain. Because the requesting host is not Public Suffix List (PSL) normalized, an attacker-controlled tenant subdomain on a PSL-listed private registry of the apex domain is incorrectly treated as a valid match. Consequently, the user’s high-value apex-domain credentials could be erroneously suggested, potentially leading to plaintext credential disclosure upon selection.
Affected files:
ios/chrome/credential_provider_extension/ui/credential_list_mediator.mmios/chrome/credential_provider_extension/ui/ui_util.mmios/chrome/browser/credential_provider/model/archivable_credential+password_form.mm
Estimated timestamp from git blame: 2026-04-29
Potential Root Cause
In ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm, the matching logic performed in -[CredentialListMediator passwordCredential:matchesServiceIdentifiers:] evaluates whether a stored credential matches the requesting service identifier (serviceIdentifier).
While the stored credential’s registryControlledDomain is pre-computed in Chrome’s main browser process using PSL-awareness via net::registry_controlled_domains::GetDomainAndRegistry with INCLUDE_PRIVATE_REGISTRIES (in archivable_credential+password_form.mm), the requesting requestedHost is extracted as a raw host string via HostForServiceIdentifier() in ui_util.mm with no PSL or eTLD+1 normalization. Due to the lack of dependency on //net in the iOS App Extension environment, the extension cannot easily perform symmetric PSL normalization on the requesting side.
The matching evaluation is defined as follows:
if (credential.registryControlledDomain.length > 0) {
NSString* domainSuffix = [NSString
stringWithFormat:@".%@", credential.registryControlledDomain];
if ([requestedHost isEqualToString:credential.registryControlledDomain] ||
[requestedHost hasSuffix:domainSuffix]) {
return YES;
}
}
Because the requesting host is checked via hasSuffix:, this creates an asymmetric match. If a private registry is listed on the PSL under an apex domain (such as up.railway.app listed as a private registry, while the legitimate credentials reside at the apex railway.app):
- A credential stored for
https://railway.app/hasregistryControlledDomain = "railway.app". - When the user visits an attacker-controlled site under the private registry (e.g.,
https://attacker.up.railway.app/), the rawrequestedHostis"attacker.up.railway.app". - The check
[@"attacker.up.railway.app" hasSuffix:@".railway.app"]evaluates toYES, causing the credential provider to treat this as a valid match.
Potential Exploit Scenario
Note: These are suggested/potential steps, as our tooling agent does not have the ability to run code on an iOS device to verify this interactively.
- A user saves a credential for a high-value apex domain, such as
https://railway.app/, in Google Chrome on iOS. - An attacker registers a tenant subdomain under a PSL-listed private registry of that apex domain (e.g.,
https://attacker.up.railway.app/). - The user visits
https://attacker.up.railway.app/inside Safari or another iOS browser. - When the user taps on the login/password fields and invokes the AutoFill Chrome Credential Provider Extension, Chrome’s matching logic incorrectly determines that the
railway.appcredential matchesattacker.up.railway.appand presents it under the “Suggested” list. - If the user selects the suggestion and completes Face ID/Touch ID authentication, the extension completes the request, filling the plaintext password directly into the attacker’s page.
Suggested Fix
To remediate this issue, the matching logic should be updated to prevent raw suffix matching across PSL boundaries:
- Since the full
//netlibrary is too heavy to link into the extension, consider embedding a lightweight compiled Trie of known public suffixes/private registries or a highly optimized subset of the PSL directly into the credential provider extension bundle. - Alternatively, perform strict origin/host matching, or design a symmetric validation mechanism to ensure that credentials are only suggested for subdomains that share the same verified eTLD+1 as the requesting application context.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.