From 1d77d05e7c8aaba181bb5098b0bc5b1ddc9c3282 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 15 Apr 2025 16:36:45 +0200 Subject: [PATCH] Improve autofill matching (#801) --- .../src/entrypoints/contentScript/Filter.ts | 106 ++++++++++++------ .../src/entrypoints/contentScript/Popup.ts | 1 + 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/browser-extension/src/entrypoints/contentScript/Filter.ts b/browser-extension/src/entrypoints/contentScript/Filter.ts index b18e6e347..896ab62d4 100644 --- a/browser-extension/src/entrypoints/contentScript/Filter.ts +++ b/browser-extension/src/entrypoints/contentScript/Filter.ts @@ -1,63 +1,103 @@ import { CombinedStopWords } from "@/utils/formDetector/FieldPatterns"; import { Credential } from "../../utils/types/Credential"; +interface CredentialWithPriority extends Credential { + priority: number; +} + /** * Filter credentials based on current URL and page context to determine which credentials to show - * in the autofill popup. + * in the autofill popup. Credentials are sorted by priority: + * 1. Exact URL match (highest priority) + * 2. Base URL match AND page title word match + * 3. Base URL match only + * 4. Page title word match only (lowest priority) */ export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string): Credential[] { const urlObject = new URL(currentUrl); const baseUrl = `${urlObject.protocol}//${urlObject.hostname}`; + const filtered: CredentialWithPriority[] = []; - // 1. Exact URL match - let filtered = credentials.filter(cred => - cred.ServiceUrl?.toLowerCase() === currentUrl.toLowerCase() - ); + const sanitizedCurrentUrl = currentUrl.toLowerCase().replace('www.', ''); - // 2. Base URL match with fuzzy domain comparison if no exact matches - filtered = filtered.concat(credentials.filter(cred => { - if (!cred.ServiceUrl) { - return false; + // 1. Exact URL match (priority 1) + credentials.forEach(cred => { + if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) { + return; } + + const sanitizedCredUrl = cred.ServiceUrl.toLowerCase().replace('www.', ''); + + if (sanitizedCurrentUrl.startsWith(sanitizedCredUrl)) { + filtered.push({ ...cred, priority: 1 }); + } + }); + + // If we have one or more exact matches, do not continue to other matches + if (filtered.length > 0) { + return filtered; + } + + // Prepare page title words for matching + const titleWords = pageTitle.length > 0 + ? pageTitle.toLowerCase() + .split(/\s+/) + .filter(word => + word.length > 3 && + !CombinedStopWords.has(word.toLowerCase()) + ) + : []; + + // Check for base URL matches and page title matches + credentials.forEach(cred => { + if (!cred.ServiceUrl || filtered.some(f => f.Id === cred.Id)) { + return; + } + + let hasBaseUrlMatch = false; + let hasTitleMatch = false; + + // Check base URL match try { const credUrlObject = new URL(cred.ServiceUrl); const currentUrlObject = new URL(baseUrl); - // Extract root domains by splitting on dots and taking last two parts const credDomainParts = credUrlObject.hostname.toLowerCase().split('.'); const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.'); - // Get root domain (last two parts, e.g., 'aliasvault.net') const credRootDomain = credDomainParts.slice(-2).join('.'); const currentRootDomain = currentDomainParts.slice(-2).join('.'); - // Compare protocols and root domains - return credUrlObject.protocol === currentUrlObject.protocol && - credRootDomain === currentRootDomain; + if (credUrlObject.protocol === currentUrlObject.protocol && + credRootDomain === currentRootDomain) { + hasBaseUrlMatch = true; + } } catch { - return false; + // Invalid URL, skip } - })); - // 3. Page title word match if still no matches - if (filtered.length === 0 && pageTitle.length > 0) { - const titleWords = pageTitle.toLowerCase() - .split(/\s+/) - .filter(word => - word.length > 3 && // Filter out words shorter than 4 characters - !CombinedStopWords.has(word.toLowerCase()) // Filter out generic words - ); + // Check page title match + if (titleWords.length > 0) { + hasTitleMatch = titleWords.some(word => cred.ServiceName.toLowerCase().includes(word)); + } - filtered = credentials.filter(cred => - titleWords.some(word => - cred.ServiceName.toLowerCase().includes(word) - ) - ); - } - - // Ensure we have unique credentials - const uniqueCredentials = Array.from(new Map(filtered.map(cred => [cred.Id, cred])).values()); + // Assign priority based on matches + if (hasBaseUrlMatch && hasTitleMatch) { + filtered.push({ ...cred, priority: 2 }); + } else if (hasBaseUrlMatch) { + filtered.push({ ...cred, priority: 3 }); + } else if (hasTitleMatch) { + filtered.push({ ...cred, priority: 4 }); + } + }); + // Sort by priority and then take unique credentials + const uniqueCredentials = Array.from( + new Map(filtered + .sort((a, b) => a.priority - b.priority) + .map(cred => [cred.Id, cred])) + .values() + ); // Show max 3 results return uniqueCredentials.slice(0, 3); } diff --git a/browser-extension/src/entrypoints/contentScript/Popup.ts b/browser-extension/src/entrypoints/contentScript/Popup.ts index c17d28a70..95fabf276 100644 --- a/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -11,6 +11,7 @@ import { PasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettin import SqliteClient from '../../utils/SqliteClient'; import { BaseIdentityGenerator } from '@/utils/generators/Identity/implementations/base/BaseIdentityGenerator'; import { StringResponse } from '@/utils/types/messaging/StringResponse'; +import { Credential } from '@/utils/types/Credential'; // TODO: store generic setting constants somewhere else. export const DISABLED_SITES_KEY = 'local:aliasvault_disabled_sites';