Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Chrome for iOS
DescriptionInsufficient validation of untrusted input in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker517073397
Fix commit67a8f29c4ffb (chromium/src) +352/-23
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
ios/build/config.gni
modified
source_set
ios/chrome/credential_provider_extension/ui/BUILD.gn
modified

Files Changed

  • ios/build/config.gni
  • ios/chrome/credential_provider_extension/BUILD.gn
  • ios/chrome/credential_provider_extension/passkey_request_details.mm
  • ios/chrome/credential_provider_extension/passkey_request_details_unittest.mm
  • ios/chrome/credential_provider_extension/ui/BUILD.gn
From 67a8f29c4ffb37f0be20519e89b373b17b5792f9 Mon Sep 17 00:00:00 2001
From: Alexis Hétu <sugoi@chromium.org>
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 <tmartino@chromium.org>
Commit-Queue: Alexis Hétu <sugoi@chromium.org>
Reviewed-by: Sylvain Defresne <sdefresne@chromium.org>
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",
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.mm
  • ios/chrome/credential_provider_extension/ui/ui_util.mm
  • ios/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):

  1. A credential stored for https://railway.app/ has registryControlledDomain = "railway.app".
  2. When the user visits an attacker-controlled site under the private registry (e.g., https://attacker.up.railway.app/), the raw requestedHost is "attacker.up.railway.app".
  3. The check [@"attacker.up.railway.app" hasSuffix:@".railway.app"] evaluates to YES, 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.

  1. A user saves a credential for a high-value apex domain, such as https://railway.app/, in Google Chrome on iOS.
  2. An attacker registers a tenant subdomain under a PSL-listed private registry of that apex domain (e.g., https://attacker.up.railway.app/).
  3. The user visits https://attacker.up.railway.app/ inside Safari or another iOS browser.
  4. 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.app credential matches attacker.up.railway.app and presents it under the “Suggested” list.
  5. 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:

  1. Since the full //net library 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.
  2. 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.

View on issue tracker