From 216875ef05f8fffe55ca89fb79a1c2392b47f203 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 23 Sep 2025 10:55:24 +0200 Subject: [PATCH] Add common two level public TLDs to autofill matching implementations (#1264) --- .../src/entrypoints/contentScript/Filter.ts | 94 +++++++++++++++++-- .../contentScript/__tests__/Filter.test.ts | 38 ++++++++ .../app/autofill/utils/CredentialMatcher.kt | 81 +++++++++++++++- .../app/nativevaultmanager/AutofillTest.kt | 36 +++++++ .../ios/VaultUI/Utils/CredentialFilter.swift | 77 ++++++++++++++- .../VaultUITests/CredentialFilterTests.swift | 26 +++++ 6 files changed, 342 insertions(+), 10 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/contentScript/Filter.ts b/apps/browser-extension/src/entrypoints/contentScript/Filter.ts index 0557b553f..94d6863a0 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Filter.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Filter.ts @@ -36,6 +36,90 @@ function extractDomain(url: string): string { return domain; } +/** + * Extract root domain from a domain string. + * E.g., "sub.example.com" -> "example.com" + * E.g., "sub.example.com.au" -> "example.com.au" + * E.g., "sub.example.co.uk" -> "example.co.uk" + */ +function extractRootDomain(domain: string): string { + const parts = domain.split('.'); + if (parts.length < 2) return domain; + + // Common two-level public TLDs + const twoLevelTlds = new Set([ + // Australia + 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au', + // United Kingdom + 'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'plc.uk', 'ltd.uk', 'me.uk', + // Canada + 'co.ca', 'net.ca', 'org.ca', 'gc.ca', 'ab.ca', 'bc.ca', 'mb.ca', 'nb.ca', 'nf.ca', 'nl.ca', 'ns.ca', 'nt.ca', 'nu.ca', + 'on.ca', 'pe.ca', 'qc.ca', 'sk.ca', 'yk.ca', + // India + 'co.in', 'net.in', 'org.in', 'edu.in', 'gov.in', 'ac.in', 'res.in', 'gen.in', 'firm.in', 'ind.in', + // Japan + 'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'ad.jp', 'ed.jp', 'go.jp', 'gr.jp', 'lg.jp', + // South Africa + 'co.za', 'net.za', 'org.za', 'edu.za', 'gov.za', 'ac.za', 'web.za', + // New Zealand + 'co.nz', 'net.nz', 'org.nz', 'edu.nz', 'govt.nz', 'ac.nz', 'geek.nz', 'gen.nz', 'kiwi.nz', 'maori.nz', 'mil.nz', 'school.nz', + // Brazil + 'com.br', 'net.br', 'org.br', 'edu.br', 'gov.br', 'mil.br', 'art.br', 'etc.br', 'adv.br', 'arq.br', 'bio.br', 'cim.br', + 'cng.br', 'cnt.br', 'ecn.br', 'eng.br', 'esp.br', 'eti.br', 'far.br', 'fnd.br', 'fot.br', 'fst.br', 'g12.br', 'geo.br', + 'ggf.br', 'jor.br', 'lel.br', 'mat.br', 'med.br', 'mus.br', 'not.br', 'ntr.br', 'odo.br', 'ppg.br', 'pro.br', 'psc.br', + 'psi.br', 'qsl.br', 'rec.br', 'slg.br', 'srv.br', 'tmp.br', 'trd.br', 'tur.br', 'tv.br', 'vet.br', 'zlg.br', + // Russia + 'com.ru', 'net.ru', 'org.ru', 'edu.ru', 'gov.ru', 'int.ru', 'mil.ru', 'spb.ru', 'msk.ru', + // China + 'com.cn', 'net.cn', 'org.cn', 'edu.cn', 'gov.cn', 'mil.cn', 'ac.cn', 'ah.cn', 'bj.cn', 'cq.cn', 'fj.cn', 'gd.cn', 'gs.cn', + 'gz.cn', 'gx.cn', 'ha.cn', 'hb.cn', 'he.cn', 'hi.cn', 'hk.cn', 'hl.cn', 'hn.cn', 'jl.cn', 'js.cn', 'jx.cn', 'ln.cn', 'mo.cn', + 'nm.cn', 'nx.cn', 'qh.cn', 'sc.cn', 'sd.cn', 'sh.cn', 'sn.cn', 'sx.cn', 'tj.cn', 'tw.cn', 'xj.cn', 'xz.cn', 'yn.cn', 'zj.cn', + // Mexico + 'com.mx', 'net.mx', 'org.mx', 'edu.mx', 'gob.mx', + // Argentina + 'com.ar', 'net.ar', 'org.ar', 'edu.ar', 'gov.ar', 'mil.ar', 'int.ar', + // Chile + 'com.cl', 'net.cl', 'org.cl', 'edu.cl', 'gov.cl', 'mil.cl', + // Colombia + 'com.co', 'net.co', 'org.co', 'edu.co', 'gov.co', 'mil.co', 'nom.co', + // Venezuela + 'com.ve', 'net.ve', 'org.ve', 'edu.ve', 'gov.ve', 'mil.ve', 'web.ve', + // Peru + 'com.pe', 'net.pe', 'org.pe', 'edu.pe', 'gob.pe', 'mil.pe', 'nom.pe', + // Ecuador + 'com.ec', 'net.ec', 'org.ec', 'edu.ec', 'gov.ec', 'mil.ec', 'med.ec', 'fin.ec', 'pro.ec', 'info.ec', + // Europe + 'co.at', 'or.at', 'ac.at', 'gv.at', 'priv.at', + 'co.be', 'ac.be', + 'co.dk', 'ac.dk', + 'co.il', 'net.il', 'org.il', 'ac.il', 'gov.il', 'idf.il', 'k12.il', 'muni.il', + 'co.no', 'ac.no', 'priv.no', + 'co.pl', 'net.pl', 'org.pl', 'edu.pl', 'gov.pl', 'mil.pl', 'nom.pl', 'com.pl', + 'co.th', 'net.th', 'org.th', 'edu.th', 'gov.th', 'mil.th', 'ac.th', 'in.th', + 'co.kr', 'net.kr', 'org.kr', 'edu.kr', 'gov.kr', 'mil.kr', 'ac.kr', 'go.kr', 'ne.kr', 'or.kr', 'pe.kr', 're.kr', 'seoul.kr', + 'kyonggi.kr', + // Others + 'co.id', 'net.id', 'org.id', 'edu.id', 'gov.id', 'mil.id', 'web.id', 'ac.id', 'sch.id', + 'co.ma', 'net.ma', 'org.ma', 'edu.ma', 'gov.ma', 'ac.ma', 'press.ma', + 'co.ke', 'net.ke', 'org.ke', 'edu.ke', 'gov.ke', 'ac.ke', 'go.ke', 'info.ke', 'me.ke', 'mobi.ke', 'sc.ke', + 'co.ug', 'net.ug', 'org.ug', 'edu.ug', 'gov.ug', 'ac.ug', 'sc.ug', 'go.ug', 'ne.ug', 'or.ug', + 'co.tz', 'net.tz', 'org.tz', 'edu.tz', 'gov.tz', 'ac.tz', 'go.tz', 'hotel.tz', 'info.tz', 'me.tz', 'mil.tz', 'mobi.tz', + 'ne.tz', 'or.tz', 'sc.tz', 'tv.tz', + ]); + + // Check if the last two parts form a known two-level TLD + if (parts.length >= 3) { + const lastTwoParts = parts.slice(-2).join('.'); + if (twoLevelTlds.has(lastTwoParts)) { + // Take the last three parts for two-level TLDs + return parts.slice(-3).join('.'); + } + } + + // Default to last two parts for regular TLDs + return parts.length >= 2 ? parts.slice(-2).join('.') : domain; +} + /** * Check if two domains match, supporting partial matches * @param domain1 - First domain @@ -60,13 +144,9 @@ function domainsMatch(domain1: string, domain2: string): boolean { return true; } - // Extract root domains for comparison - const d1Parts = d1.split('.'); - const d2Parts = d2.split('.'); - - // Get the last 2 parts (domain.tld) for comparison - const d1Root = d1Parts.slice(-2).join('.'); - const d2Root = d2Parts.slice(-2).join('.'); + // Check root domain match + const d1Root = extractRootDomain(d1); + const d2Root = extractRootDomain(d2); return d1Root === d2Root; } diff --git a/apps/browser-extension/src/entrypoints/contentScript/__tests__/Filter.test.ts b/apps/browser-extension/src/entrypoints/contentScript/__tests__/Filter.test.ts index 8922b164c..767f0a79e 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/__tests__/Filter.test.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/__tests__/Filter.test.ts @@ -292,6 +292,44 @@ describe('Filter - Credential URL Matching', () => { expect(matches[0].ServiceName).toBe('Reddit'); }); + // [#20] - Test multi-part TLDs like .com.au don't match incorrectly + it('should handle multi-part TLDs correctly without false matches', () => { + // Create test data with different .com.au domains + const australianCredentials = [ + createTestCredential('Example Site AU', 'https://example.com.au', 'user@example.com.au'), + createTestCredential('BlaBla AU', 'https://blabla.blabla.com.au', 'user@blabla.com.au'), + createTestCredential('Another AU', 'https://another.com.au', 'user@another.com.au'), + createTestCredential('UK Site', 'https://example.co.uk', 'user@example.co.uk'), + ]; + + // Test that blabla.blabla.com.au doesn't match other .com.au sites + const blablaMatches = filterCredentials( + australianCredentials, + 'https://blabla.blabla.com.au', + '' + ); + expect(blablaMatches).toHaveLength(1); + expect(blablaMatches[0].ServiceName).toBe('BlaBla AU'); + + // Test that example.com.au doesn't match blabla.blabla.com.au + const exampleMatches = filterCredentials( + australianCredentials, + 'https://example.com.au', + '' + ); + expect(exampleMatches).toHaveLength(1); + expect(exampleMatches[0].ServiceName).toBe('Example Site AU'); + + // Test that .co.uk domains work correctly too + const ukMatches = filterCredentials( + australianCredentials, + 'https://example.co.uk', + '' + ); + expect(ukMatches).toHaveLength(1); + expect(ukMatches[0].ServiceName).toBe('UK Site'); + }); + /** * Creates the shared test credential dataset used across all platforms. * Note: when making changes to this list, make sure to update the corresponding list for iOS and Android tests as well. diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt index 61c9f1ceb..946ca39b8 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt @@ -51,10 +51,89 @@ object CredentialMatcher { /** * Extract root domain from a domain string. * E.g., "sub.example.com" -> "example.com" + * E.g., "sub.example.com.au" -> "example.com.au" + * E.g., "sub.example.co.uk" -> "example.co.uk" */ private fun extractRootDomain(domain: String): String { val parts = domain.split(".") - return if (parts.size >= 2) parts.takeLast(2).joinToString(".") else domain + if (parts.size < 2) return domain + + // Common two-level public TLDs + val twoLevelTlds = setOf( + // Australia + "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", + // United Kingdom + "co.uk", "org.uk", "net.uk", "ac.uk", "gov.uk", "plc.uk", "ltd.uk", "me.uk", + // Canada + "co.ca", "net.ca", "org.ca", "gc.ca", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca", + "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", + // India + "co.in", "net.in", "org.in", "edu.in", "gov.in", "ac.in", "res.in", "gen.in", "firm.in", "ind.in", + // Japan + "co.jp", "ne.jp", "or.jp", "ac.jp", "ad.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", + // South Africa + "co.za", "net.za", "org.za", "edu.za", "gov.za", "ac.za", "web.za", + // New Zealand + "co.nz", "net.nz", "org.nz", "edu.nz", "govt.nz", "ac.nz", "geek.nz", "gen.nz", "kiwi.nz", "maori.nz", "mil.nz", "school.nz", + // Brazil + "com.br", "net.br", "org.br", "edu.br", "gov.br", "mil.br", "art.br", "etc.br", "adv.br", "arq.br", "bio.br", "cim.br", + "cng.br", "cnt.br", "ecn.br", "eng.br", "esp.br", "eti.br", "far.br", "fnd.br", "fot.br", "fst.br", "g12.br", "geo.br", + "ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br", "odo.br", "ppg.br", "pro.br", "psc.br", + "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", "trd.br", "tur.br", "tv.br", "vet.br", "zlg.br", + // Russia + "com.ru", "net.ru", "org.ru", "edu.ru", "gov.ru", "int.ru", "mil.ru", "spb.ru", "msk.ru", + // China + "com.cn", "net.cn", "org.cn", "edu.cn", "gov.cn", "mil.cn", "ac.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn", + "gz.cn", "gx.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", "hk.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mo.cn", + "nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", + // Mexico + "com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx", + // Argentina + "com.ar", "net.ar", "org.ar", "edu.ar", "gov.ar", "mil.ar", "int.ar", + // Chile + "com.cl", "net.cl", "org.cl", "edu.cl", "gov.cl", "mil.cl", + // Colombia + "com.co", "net.co", "org.co", "edu.co", "gov.co", "mil.co", "nom.co", + // Venezuela + "com.ve", "net.ve", "org.ve", "edu.ve", "gov.ve", "mil.ve", "web.ve", + // Peru + "com.pe", "net.pe", "org.pe", "edu.pe", "gob.pe", "mil.pe", "nom.pe", + // Ecuador + "com.ec", "net.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "med.ec", "fin.ec", "pro.ec", "info.ec", + // Europe + "co.at", "or.at", "ac.at", "gv.at", "priv.at", + "co.be", "ac.be", + "co.dk", "ac.dk", + "co.il", "net.il", "org.il", "ac.il", "gov.il", "idf.il", "k12.il", "muni.il", + "co.no", "ac.no", "priv.no", + "co.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "mil.pl", "nom.pl", "com.pl", + "co.th", "net.th", "org.th", "edu.th", "gov.th", "mil.th", "ac.th", "in.th", + "co.kr", "net.kr", "org.kr", "edu.kr", "gov.kr", "mil.kr", "ac.kr", "go.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", "seoul.kr", + "kyonggi.kr", + // Others + "co.id", "net.id", "org.id", "edu.id", "gov.id", "mil.id", "web.id", "ac.id", "sch.id", + "co.ma", "net.ma", "org.ma", "edu.ma", "gov.ma", "ac.ma", "press.ma", + "co.ke", "net.ke", "org.ke", "edu.ke", "gov.ke", "ac.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "sc.ke", + "co.ug", "net.ug", "org.ug", "edu.ug", "gov.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug", + "co.tz", "net.tz", "org.tz", "edu.tz", "gov.tz", "ac.tz", "go.tz", "hotel.tz", "info.tz", "me.tz", "mil.tz", "mobi.tz", + "ne.tz", "or.tz", "sc.tz", "tv.tz", + ) + + // Check if the last two parts form a known two-level TLD + if (parts.size >= 3) { + val lastTwoParts = parts.takeLast(2).joinToString(".") + if (twoLevelTlds.contains(lastTwoParts)) { + // Take the last three parts for two-level TLDs + return parts.takeLast(3).joinToString(".") + } + } + + // Default to last two parts for regular TLDs + return if (parts.size >= 2) { + parts.takeLast(2).joinToString(".") + } else { + domain + } } /** diff --git a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt index ad30a573a..f4101c2da 100644 --- a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt +++ b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt @@ -295,6 +295,42 @@ class AutofillTest { assertEquals("Reddit", matches[0].service.name) } + // [#20] - Test multi-part TLDs like .com.au don't match incorrectly + @Test + fun testMultiPartTldNoFalseMatches() { + // Create test data with different .com.au domains + val australianCredentials = listOf( + createTestCredential("Example Site AU", "https://example.com.au", "user@example.com.au"), + createTestCredential("BlaBla AU", "https://blabla.blabla.com.au", "user@blabla.com.au"), + createTestCredential("Another AU", "https://another.com.au", "user@another.com.au"), + createTestCredential("UK Site", "https://example.co.uk", "user@example.co.uk"), + ) + + // Test that blabla.blabla.com.au doesn't match other .com.au sites + val blablaMatches = CredentialMatcher.filterCredentialsByAppInfo( + australianCredentials, + "https://blabla.blabla.com.au", + ) + assertEquals(1, blablaMatches.size, "Should only match the exact domain, not all .com.au sites") + assertEquals("BlaBla AU", blablaMatches[0].service.name) + + // Test that example.com.au doesn't match blabla.blabla.com.au + val exampleMatches = CredentialMatcher.filterCredentialsByAppInfo( + australianCredentials, + "https://example.com.au", + ) + assertEquals(1, exampleMatches.size, "Should only match example.com.au") + assertEquals("Example Site AU", exampleMatches[0].service.name) + + // Test that .co.uk domains work correctly too + val ukMatches = CredentialMatcher.filterCredentialsByAppInfo( + australianCredentials, + "https://example.co.uk", + ) + assertEquals(1, ukMatches.size, "Should only match the .co.uk domain") + assertEquals("UK Site", ukMatches[0].service.name) + } + /** * Creates the shared test credential dataset used across all platforms. * This ensures consistent testing across Browser Extension, iOS, and Android. diff --git a/apps/mobile-app/ios/VaultUI/Utils/CredentialFilter.swift b/apps/mobile-app/ios/VaultUI/Utils/CredentialFilter.swift index 3b1f44e31..99b7e3337 100644 --- a/apps/mobile-app/ios/VaultUI/Utils/CredentialFilter.swift +++ b/apps/mobile-app/ios/VaultUI/Utils/CredentialFilter.swift @@ -58,10 +58,83 @@ public class CredentialFilter { /// Extract root domain from a domain string. /// - Parameter domain: Domain string - /// - Returns: Root domain (e.g., "sub.example.com" -> "example.com") + /// - Returns: Root domain (e.g., "sub.example.com" -> "example.com", "sub.example.com.au" -> "example.com.au", "sub.example.co.uk" -> "example.co.uk") private static func extractRootDomain(from domain: String) -> String { let parts = domain.components(separatedBy: ".") - return parts.count >= 2 ? parts.suffix(2).joined(separator: ".") : domain + guard parts.count >= 2 else { return domain } + + // Common two-level public TLDs + let twoLevelTlds: Set = [ + // Australia + "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", + // United Kingdom + "co.uk", "org.uk", "net.uk", "ac.uk", "gov.uk", "plc.uk", "ltd.uk", "me.uk", + // Canada + "co.ca", "net.ca", "org.ca", "gc.ca", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", + // India + "co.in", "net.in", "org.in", "edu.in", "gov.in", "ac.in", "res.in", "gen.in", "firm.in", "ind.in", + // Japan + "co.jp", "ne.jp", "or.jp", "ac.jp", "ad.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", + // South Africa + "co.za", "net.za", "org.za", "edu.za", "gov.za", "ac.za", "web.za", + // New Zealand + "co.nz", "net.nz", "org.nz", "edu.nz", "govt.nz", "ac.nz", "geek.nz", "gen.nz", "kiwi.nz", "maori.nz", "mil.nz", "school.nz", + // Brazil + "com.br", "net.br", "org.br", "edu.br", "gov.br", "mil.br", "art.br", "etc.br", "adv.br", "arq.br", "bio.br", "cim.br", "cng.br", "cnt.br", "ecn.br", "eng.br", + "esp.br", "eti.br", "far.br", "fnd.br", "fot.br", "fst.br", "g12.br", "geo.br", "ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br", + "odo.br", "ppg.br", "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", "trd.br", "tur.br", "tv.br", "vet.br", "zlg.br", + // Russia + "com.ru", "net.ru", "org.ru", "edu.ru", "gov.ru", "int.ru", "mil.ru", "spb.ru", "msk.ru", + // China + "com.cn", "net.cn", "org.cn", "edu.cn", "gov.cn", "mil.cn", "ac.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn", "gz.cn", "gx.cn", "ha.cn", "hb.cn", + "he.cn", "hi.cn", "hk.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mo.cn", "nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", + "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", + // Mexico + "com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx", + // Argentina + "com.ar", "net.ar", "org.ar", "edu.ar", "gov.ar", "mil.ar", "int.ar", + // Chile + "com.cl", "net.cl", "org.cl", "edu.cl", "gov.cl", "mil.cl", + // Colombia + "com.co", "net.co", "org.co", "edu.co", "gov.co", "mil.co", "nom.co", + // Venezuela + "com.ve", "net.ve", "org.ve", "edu.ve", "gov.ve", "mil.ve", "web.ve", + // Peru + "com.pe", "net.pe", "org.pe", "edu.pe", "gob.pe", "mil.pe", "nom.pe", + // Ecuador + "com.ec", "net.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "med.ec", "fin.ec", "pro.ec", "info.ec", + // Europe + "co.at", "or.at", "ac.at", "gv.at", "priv.at", + "co.be", "ac.be", + "co.dk", "ac.dk", + "co.il", "net.il", "org.il", "ac.il", "gov.il", "idf.il", "k12.il", "muni.il", + "co.no", "ac.no", "priv.no", + "co.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "mil.pl", "nom.pl", "com.pl", + "co.th", "net.th", "org.th", "edu.th", "gov.th", "mil.th", "ac.th", "in.th", + "co.kr", "net.kr", "org.kr", "edu.kr", "gov.kr", "mil.kr", "ac.kr", "go.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", "seoul.kr", "kyonggi.kr", + // Others + "co.id", "net.id", "org.id", "edu.id", "gov.id", "mil.id", "web.id", "ac.id", "sch.id", + "co.ma", "net.ma", "org.ma", "edu.ma", "gov.ma", "ac.ma", "press.ma", + "co.ke", "net.ke", "org.ke", "edu.ke", "gov.ke", "ac.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "sc.ke", + "co.ug", "net.ug", "org.ug", "edu.ug", "gov.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug", + "co.tz", "net.tz", "org.tz", "edu.tz", "gov.tz", "ac.tz", "go.tz", "hotel.tz", "info.tz", "me.tz", "mil.tz", "mobi.tz", "ne.tz", "or.tz", "sc.tz", "tv.tz" + ] + + // Check if the last two parts form a known two-level TLD + if parts.count >= 3 { + let lastTwoParts = parts.suffix(2).joined(separator: ".") + if twoLevelTlds.contains(lastTwoParts) { + // Take the last three parts for two-level TLDs + return parts.suffix(3).joined(separator: ".") + } + } + + // Default to last two parts for regular TLDs + if parts.count >= 2 { + return parts.suffix(2).joined(separator: ".") + } + + return domain } /// Check if two domains match, supporting partial matches. diff --git a/apps/mobile-app/ios/VaultUITests/CredentialFilterTests.swift b/apps/mobile-app/ios/VaultUITests/CredentialFilterTests.swift index efb6de833..d15d6dd24 100644 --- a/apps/mobile-app/ios/VaultUITests/CredentialFilterTests.swift +++ b/apps/mobile-app/ios/VaultUITests/CredentialFilterTests.swift @@ -189,6 +189,32 @@ final class CredentialFilterTests: XCTestCase { XCTAssertEqual(matches.first?.service.name, "Reddit") } + // [#20] - Test multi-part TLDs like .com.au don't match incorrectly + func testMultiPartTldNoFalseMatches() { + // Create test data with different .com.au domains + let australianCredentials = [ + createTestCredential(serviceName: "Example Site AU", serviceUrl: "https://example.com.au", username: "user@example.com.au"), + createTestCredential(serviceName: "BlaBla AU", serviceUrl: "https://blabla.blabla.com.au", username: "user@blabla.com.au"), + createTestCredential(serviceName: "Another AU", serviceUrl: "https://another.com.au", username: "user@another.com.au"), + createTestCredential(serviceName: "UK Site", serviceUrl: "https://example.co.uk", username: "user@example.co.uk"), + ] + + // Test that blabla.blabla.com.au doesn't match other .com.au sites + let blablaMatches = CredentialFilter.filterCredentials(australianCredentials, searchText: "https://blabla.blabla.com.au") + XCTAssertEqual(blablaMatches.count, 1, "Should only match the exact domain, not all .com.au sites") + XCTAssertEqual(blablaMatches.first?.service.name, "BlaBla AU") + + // Test that example.com.au doesn't match blabla.blabla.com.au + let exampleMatches = CredentialFilter.filterCredentials(australianCredentials, searchText: "https://example.com.au") + XCTAssertEqual(exampleMatches.count, 1, "Should only match example.com.au") + XCTAssertEqual(exampleMatches.first?.service.name, "Example Site AU") + + // Test that .co.uk domains work correctly too + let ukMatches = CredentialFilter.filterCredentials(australianCredentials, searchText: "https://example.co.uk") + XCTAssertEqual(ukMatches.count, 1, "Should only match the .co.uk domain") + XCTAssertEqual(ukMatches.first?.service.name, "UK Site") + } + // MARK: - Shared Test Data /**