Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in iOS
DescriptionInappropriate implementation in iOS
ComponentChromium
Bug ClassLogic Error
Tracker504184408
Fix commit79da955bb2ec (chromium/src) +125/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
for
ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
modified
if
ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
modified
TEST_F
ios/chrome/credential_provider_extension/ui/credential_list_mediator_unittest.mm
modified

Files Changed

  • ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
  • ios/chrome/credential_provider_extension/ui/credential_list_mediator_unittest.mm
From 79da955bb2ecff5b0545e566affc9650fa3ee3a4 Mon Sep 17 00:00:00 2001
From: Alexis Hétu <[email protected]>
Date: Wed, 29 Apr 2026 07:59:48 -0700
Subject: [PATCH] [iOS] Fix CPE password domain matching

This CL replaces the CPE's string based domain comparison with
a more robust host comparison. Tests are also added to verify
that password filtering still works properly and also removes
passwords from false matches.

Bug: 504184408
Change-Id: If6df59a5ea1160ebeb6190c1c21fefeb12ea1138
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7796497
Commit-Queue: Alexis Hétu <[email protected]>
Reviewed-by: Tommy Martino <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1622457}
---

diff --git a/ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm b/ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
index 0eddd674..64d5a5c 100644
--- a/ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
+++ b/ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
@@ -217,17 +217,35 @@
 - (BOOL)passwordCredential:(id<Credential>)credential
     matchesServiceIdentifiers:
         (NSArray<ASCredentialServiceIdentifier*>*)serviceIdentifiers {
-  for (ASCredentialServiceIdentifier* identifier in serviceIdentifiers) {
-    BOOL serviceNameMatches =
-        credential.serviceName &&
-        [identifier.identifier
-            localizedStandardContainsString:credential.serviceName];
-    BOOL serviceIdentifierMatches =
-        credential.serviceIdentifier &&
-        [identifier.identifier
-            localizedStandardContainsString:credential.serviceIdentifier];
-    if (serviceNameMatches || serviceIdentifierMatches) {
-      return YES;
+  for (ASCredentialServiceIdentifier* serviceIdentifier in serviceIdentifiers) {
+    NSString* requestedHost = HostForServiceIdentifier(serviceIdentifier);
+    if (!requestedHost) {
+      continue;
+    }
+
+    // Try matching with registryControlledDomain if available.
+    if (credential.registryControlledDomain.length > 0) {
+      NSString* domainSuffix = [NSString
+          stringWithFormat:@".%@", credential.registryControlledDomain];
+      if ([requestedHost isEqualToString:credential.registryControlledDomain] ||
+          [requestedHost hasSuffix:domainSuffix]) {
+        return YES;
+      }
+    }
+
+    // Fallback to matching the parsed host of the credential's
+    // serviceIdentifier.
+    NSURL* credURL = credential.serviceIdentifier
+                         ? [NSURL URLWithString:credential.serviceIdentifier]
+                         : nil;
+    NSString* credHost = credURL.host ?: credential.serviceIdentifier;
+
+    if (credHost.length > 0) {
+      NSString* credDomainSuffix = [NSString stringWithFormat:@".%@", credHost];
+      if ([requestedHost isEqualToString:credHost] ||
+          [requestedHost hasSuffix:credDomainSuffix]) {
+        return YES;
+      }
     }
   }
   return NO;
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 dac9be3..2571d612 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
@@ -435,4 +435,100 @@
   EXPECT_NSEQ(filtered_credentials[1], passkey_credential);
 }
 
+// Tests that filtering password credentials works properly for subdomains.
+TEST_F(CredentialListMediatorTest, FilterPasswordCredentialsSubdomain) {
+  ArchivableCredential* credential =
+      [[ArchivableCredential alloc] initWithFavicon:nil
+                                               gaia:nil
+                                           password:@"qwerty123"
+                                               rank:1
+                                   recordIdentifier:@"recordIdentifier"
+                                  serviceIdentifier:@"http://example.com"
+                                        serviceName:@"example.com"
+                           registryControlledDomain:@"example.com"
+                                           username:@"username_value"
+                                               note:@"note"
+                                       lastUsedTime:0];
+
+  NSMutableArray<id<Credential>>* credentials = [NSMutableArray array];
+  [credentials addObject:credential];
+  id<CredentialStore> credentialStore =
+      [[MockCredentialStore alloc] initWithCredentials:credentials];
+
+  ASCredentialServiceIdentifier* serviceIdentifier =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"login.example.com"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+  NSArray* serviceIdentifiers = [NSArray arrayWithObject:serviceIdentifier];
+
+  CredentialListMediator* credentialListMediator =
+      [[CredentialListMediator alloc] initWithConsumer:nil
+                                             UIHandler:nil
+                                       credentialStore:credentialStore
+                                    serviceIdentifiers:serviceIdentifiers
+                             credentialResponseHandler:nil];
+
+  credentialListMediator.allCredentials =
+      [credentialListMediator fetchAllCredentials];
+
+  NSArray<id<Credential>>* filteredCredentials =
+      [credentialListMediator filterCredentials];
+  ASSERT_EQ(filteredCredentials.count, 1u);
+  EXPECT_NSEQ(filteredCredentials[0], credential);
+}
+
+// Tests that filtering password credentials rejects false matches.
+TEST_F(CredentialListMediatorTest, FilterPasswordCredentialsNoFalseMatch) {
+  ArchivableCredential* credential =
+      [[ArchivableCredential alloc] initWithFavicon:nil
+                                               gaia:nil
+                                           password:@"qwerty123"
+                                               rank:1
+                                   recordIdentifier:@"recordIdentifier"
+                                  serviceIdentifier:@"http://example.com"
+                                        serviceName:@"example.com"
+                           registryControlledDomain:@"example.com"
+                                           username:@"username_value"
+                                               note:@"note"
+                                       lastUsedTime:0];
+
+  NSMutableArray<id<Credential>>* credentials = [NSMutableArray array];
+  [credentials addObject:credential];
+  id<CredentialStore> credentialStore =
+      [[MockCredentialStore alloc] initWithCredentials:credentials];
+
+  ASCredentialServiceIdentifier* serviceIdentifier1 =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"evil-example.com"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+
+  ASCredentialServiceIdentifier* serviceIdentifier2 =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"example.com.evil"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+
+  ASCredentialServiceIdentifier* serviceIdentifier3 =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"evil.com/login?target=example.com"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+
+  NSArray* serviceIdentifiers =
+      [NSArray arrayWithObjects:serviceIdentifier1, serviceIdentifier2,
+                                serviceIdentifier3, nil];
+
+  CredentialListMediator* credentialListMediator =
+      [[CredentialListMediator alloc] initWithConsumer:nil
+                                             UIHandler:nil
+                                       credentialStore:credentialStore
+                                    serviceIdentifiers:serviceIdentifiers
+                             credentialResponseHandler:nil];
+
+  credentialListMediator.allCredentials =
+      [credentialListMediator fetchAllCredentials];
+
+  NSArray<id<Credential>>* filteredCredentials =
+      [credentialListMediator filterCredentials];
+  ASSERT_EQ(filteredCredentials.count, 0u);
+}
+
 }  // namespace credential_provider_extension
Loading diff…

Regression Test / PoC

shipped with the fix
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 dac9be3..2571d612 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
@@ -435,4 +435,100 @@
   EXPECT_NSEQ(filtered_credentials[1], passkey_credential);
 }
 
+// Tests that filtering password credentials works properly for subdomains.
+TEST_F(CredentialListMediatorTest, FilterPasswordCredentialsSubdomain) {
+  ArchivableCredential* credential =
+      [[ArchivableCredential alloc] initWithFavicon:nil
+                                               gaia:nil
+                                           password:@"qwerty123"
+                                               rank:1
+                                   recordIdentifier:@"recordIdentifier"
+                                  serviceIdentifier:@"http://example.com"
+                                        serviceName:@"example.com"
+                           registryControlledDomain:@"example.com"
+                                           username:@"username_value"
+                                               note:@"note"
+                                       lastUsedTime:0];
+
+  NSMutableArray<id<Credential>>* credentials = [NSMutableArray array];
+  [credentials addObject:credential];
+  id<CredentialStore> credentialStore =
+      [[MockCredentialStore alloc] initWithCredentials:credentials];
+
+  ASCredentialServiceIdentifier* serviceIdentifier =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"login.example.com"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+  NSArray* serviceIdentifiers = [NSArray arrayWithObject:serviceIdentifier];
+
+  CredentialListMediator* credentialListMediator =
+      [[CredentialListMediator alloc] initWithConsumer:nil
+                                             UIHandler:nil
+                                       credentialStore:credentialStore
+                                    serviceIdentifiers:serviceIdentifiers
+                             credentialResponseHandler:nil];
+
+  credentialListMediator.allCredentials =
+      [credentialListMediator fetchAllCredentials];
+
+  NSArray<id<Credential>>* filteredCredentials =
+      [credentialListMediator filterCredentials];
+  ASSERT_EQ(filteredCredentials.count, 1u);
+  EXPECT_NSEQ(filteredCredentials[0], credential);
+}
+
+// Tests that filtering password credentials rejects false matches.
+TEST_F(CredentialListMediatorTest, FilterPasswordCredentialsNoFalseMatch) {
+  ArchivableCredential* credential =
+      [[ArchivableCredential alloc] initWithFavicon:nil
+                                               gaia:nil
+                                           password:@"qwerty123"
+                                               rank:1
+                                   recordIdentifier:@"recordIdentifier"
+                                  serviceIdentifier:@"http://example.com"
+                                        serviceName:@"example.com"
+                           registryControlledDomain:@"example.com"
+                                           username:@"username_value"
+                                               note:@"note"
+                                       lastUsedTime:0];
+
+  NSMutableArray<id<Credential>>* credentials = [NSMutableArray array];
+  [credentials addObject:credential];
+  id<CredentialStore> credentialStore =
+      [[MockCredentialStore alloc] initWithCredentials:credentials];
+
+  ASCredentialServiceIdentifier* serviceIdentifier1 =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"evil-example.com"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+
+  ASCredentialServiceIdentifier* serviceIdentifier2 =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"example.com.evil"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+
+  ASCredentialServiceIdentifier* serviceIdentifier3 =
+      [[ASCredentialServiceIdentifier alloc]
+          initWithIdentifier:@"evil.com/login?target=example.com"
+                        type:ASCredentialServiceIdentifierTypeDomain];
+
+  NSArray* serviceIdentifiers =
+      [NSArray arrayWithObjects:serviceIdentifier1, serviceIdentifier2,
+                                serviceIdentifier3, nil];
+
+  CredentialListMediator* credentialListMediator =
+      [[CredentialListMediator alloc] initWithConsumer:nil
+                                             UIHandler:nil
+                                       credentialStore:credentialStore
+                                    serviceIdentifiers:serviceIdentifiers
+                             credentialResponseHandler:nil];
+
+  credentialListMediator.allCredentials =
+      [credentialListMediator fetchAllCredentials];
+
+  NSArray<id<Credential>>* filteredCredentials =
+      [credentialListMediator filterCredentials];
+  ASSERT_EQ(filteredCredentials.count, 0u);
+}
+
 }  // namespace credential_provider_extension
Loading diff…

Original Bug Report

reported by [email protected]

Potential cross-origin password disclosure in iOS Credential Provider Extension

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 go/chrome-ai-generated-security-bugs-faq for more information.

Overview: The iOS Credential Provider Extension uses an insecure substring match to determine if a saved credential matches the requesting website. An attacker can craft a URL containing a victim domain as a substring to cause Chrome to suggest the victim’s credential on the attacker’s page, leading to potential password disclosure.

Affected files:

  • ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm
  • ios/chrome/credential_provider_extension/credential_provider_view_controller.mm
  • ios/chrome/browser/credential_provider/model/archivable_credential+password_form.mm
  • ios/chrome/credential_provider_extension/ui/credential_list_view_controller.mm

Estimated timestamp from git blame: 2025-01-10

Description

There is a potential cross-origin password disclosure vulnerability in the iOS Credential Provider Extension (CPE). The logic that determines whether a stored password should be presented as ‘Suggested’ for a given website relies on an insecure substring match.

When a website requests autofill, iOS invokes the extension and passes an array of ASCredentialServiceIdentifier objects. For web requests, the identifier property contains the full URL of the requesting page (with the https:// scheme stripped by iOS).

In ios/chrome/credential_provider_extension/ui/credential_list_mediator.mm, the method -[CredentialListMediator passwordCredential:matchesServiceIdentifiers:] evaluates these identifiers against stored credentials:

BOOL serviceNameMatches =
    credential.serviceName &&
    [identifier.identifier
        localizedStandardContainsString:credential.serviceName];

The localizedStandardContainsString: method performs a basic, case-insensitive substring search. Because identifier.identifier (the haystack) is the attacker-controlled URL and credential.serviceName (the needle) is the victim domain (e.g., accounts.google.com), an attacker can simply embed the victim domain into their URL.

For example, if the attacker hosts a page at https://attacker.com/login?target=accounts.google.com, the identifier passed to Chrome will be attacker.com/login?target=accounts.google.com. The substring match against accounts.google.com will evaluate to YES.

Chrome will then prominently display the victim’s credential in the ‘Suggested’ section of the iOS AutoFill picker, complete with the trusted domain name and favicon. If the user selects the suggestion and authenticates via FaceID/TouchID, the plaintext password is provided back to the iOS framework and autofilled into the attacker’s page.

Potential Reproduction Steps

Note: These steps are based on static analysis and have not been executed with a working proof of concept.

  1. Ensure Chrome is configured as an AutoFill provider on an iOS device.
  2. Save a password in Chrome for a legitimate site, e.g., https://accounts.google.com/.
  3. Navigate to an attacker-controlled site crafted to include the victim domain as a substring, such as https://attacker.com/login?target=accounts.google.com.
  4. Tap the password field on the attacker’s page and select ‘Passwords…’. Choose Chrome if prompted.
  5. Observe that the legitimate Google credential is shown at the top of the picker in the ‘Suggested’ section.
  6. Tap the suggestion and perform biometric authentication.
  7. The victim’s password is provided to the iOS framework and autofilled into the attacker’s page.

Suggested Fix

The matching logic should be updated to enforce strict domain boundaries rather than relying on substring searches. Specifically, the extension should extract the host from the ASCredentialServiceIdentifier and compare it against the credential’s registryControlledDomain (eTLD+1) or use a robust suffix match that prepends a . (e.g., matching .example.com against sub.example.com), similar to the logic already implemented for passkey upgrades in passkey_request_details.mm.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


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.

View on issue tracker