From f86400fa501badf5829a8020a625cdddbac2eca4 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 25 Aug 2025 17:18:06 +0200 Subject: [PATCH] Add autofill matching mode configurable setting to browser extension (#1142) --- .../src/entrypoints/contentScript/Filter.ts | 194 +++++++++++------- .../src/entrypoints/contentScript/Popup.ts | 22 +- .../contentScript/__tests__/Filter.test.ts | 13 ++ .../src/entrypoints/popup/pages/Settings.tsx | 38 +++- .../src/i18n/locales/en.json | 6 + apps/browser-extension/src/utils/Constants.ts | 1 + 6 files changed, 197 insertions(+), 77 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/contentScript/Filter.ts b/apps/browser-extension/src/entrypoints/contentScript/Filter.ts index 65c80e8ee..0557b553f 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Filter.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Filter.ts @@ -1,6 +1,12 @@ import type { Credential } from '@/utils/dist/shared/models/vault'; import { CombinedStopWords } from '@/utils/formDetector/FieldPatterns'; +export enum AutofillMatchingMode { + DEFAULT = 'default', + URL_EXACT = 'url_exact', + URL_SUBDOMAIN = 'url_subdomain' +} + type CredentialWithPriority = Credential & { priority: number; } @@ -66,91 +72,141 @@ function domainsMatch(domain1: string, domain2: string): boolean { } /** - * Filter credentials based on current URL and page context with anti-phishing protection. - * - * **Security Note**: When searching with a URL, text search fallback only applies to - * credentials with no service URL defined. This prevents phishing attacks where a - * malicious site might match credentials intended for the legitimate site. - * - * Credentials are sorted by priority: - * 1. Exact domain match (highest priority) - * 2. Partial domain match (root domain match) - * 3. Page title word match (only for credentials without service URLs) + * Extract meaningful words from text, removing punctuation and filtering stop words + * @param text - Text to extract words from + * @returns Array of filtered words */ -export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string): Credential[] { +function extractWords(text: string): string[] { + if (!text || text.length === 0) { + return []; + } + + return text.toLowerCase() + // Replace common separators and punctuation with spaces + .replace(/[|,;:\-–—/\\()[\]{}'"`~!@#$%^&*+=<>?]/g, ' ') + // Split on whitespace and filter + .split(/\s+/) + .filter(word => + word.length > 3 && + !CombinedStopWords.has(word) + ); +} + +/** + * Filter credentials based on current URL and page context with anti-phishing protection. + * + * **Security Note**: When searching with a URL, text search fallback only applies to + * credentials with no service URL defined. This prevents phishing attacks where a + * malicious site might match credentials intended for the legitimate site. + * + * Credentials are sorted by priority: + * 1. Exact domain match (priority 1 - highest) + * 2. Partial/subdomain match (priority 2) + * 3. Service name fallback match (priority 5 - lowest, only for credentials without URLs) + */ +export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string, matchingMode: AutofillMatchingMode = AutofillMatchingMode.DEFAULT): Credential[] { const filtered: CredentialWithPriority[] = []; const currentDomain = extractDomain(currentUrl); - // Check each credential for matches + // Determine feature flags based on matching mode + let enableExactMatch = false; + let enableSubdomainMatch = false; + let enableServiceNameFallback = false; + + switch (matchingMode) { + case AutofillMatchingMode.URL_EXACT: + enableExactMatch = true; + enableSubdomainMatch = false; + enableServiceNameFallback = false; + break; + + case AutofillMatchingMode.URL_SUBDOMAIN: + enableExactMatch = true; + enableSubdomainMatch = true; + enableServiceNameFallback = false; + break; + + case AutofillMatchingMode.DEFAULT: + enableExactMatch = true; + enableSubdomainMatch = true; + enableServiceNameFallback = true; + break; + } + + // Process credentials with service URLs credentials.forEach(cred => { if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) { - return; + return; // Handle these in service name fallback } const credDomain = extractDomain(cred.ServiceUrl); - // Check for domain match (exact or partial) - if (domainsMatch(currentDomain, credDomain)) { - // Exact match gets higher priority - const priority = currentDomain === credDomain ? 1 : 2; - filtered.push({ ...cred, priority }); + // Check for exact match (priority 1) + if (enableExactMatch && currentDomain === credDomain) { + filtered.push({ ...cred, priority: 1 }); + return; + } + + // Check for subdomain/partial match (priority 2) + if (enableSubdomainMatch && domainsMatch(currentDomain, credDomain)) { + filtered.push({ ...cred, priority: 2 }); + return; } }); - // If we have domain matches, return them sorted by priority - if (filtered.length > 0) { - return filtered - .sort((a, b) => a.priority - b.priority) - .slice(0, 3); + // Service name fallback for credentials without URLs (priority 5) + if (enableServiceNameFallback) { + /* + * SECURITY: Service name matching only applies to credentials with no service URL. + * This prevents phishing attacks where a malicious site might match credentials + * intended for a legitimate site. + */ + + // Extract words from page title + const titleWords = extractWords(pageTitle); + + if (titleWords.length > 0) { + credentials.forEach(cred => { + // CRITICAL: Only check credentials that have NO service URL defined + if (cred.ServiceUrl && cred.ServiceUrl.length > 0) { + return; + } + + // Skip if already in filtered list + if (filtered.some(f => f.Id === cred.Id)) { + return; + } + + // Check page title match with service name + if (cred.ServiceName) { + const credNameWords = extractWords(cred.ServiceName); + + /* + * Match only complete words, not substrings + * For example: "Express" should match "My Express Account" but not "AliExpress" + */ + const hasTitleMatch = titleWords.some(titleWord => + credNameWords.some(credWord => + titleWord === credWord // Exact word match only + ) + ); + + if (hasTitleMatch) { + filtered.push({ ...cred, priority: 5 }); + } + } + }); + } } - /* - * SECURITY: Fallback to page title matching, but ONLY for credentials with no service URL - * This prevents phishing attacks by ensuring URL-based credentials only match their domains - */ - const titleWords = pageTitle.length > 0 - ? pageTitle.toLowerCase() - .split(/\s+/) - .filter(word => - word.length > 3 && - !CombinedStopWords.has(word.toLowerCase()) - ) - : []; - - // Check for page title matches as fallback - credentials.forEach(cred => { - // CRITICAL: Only check credentials that have NO service URL defined - if (cred.ServiceUrl && cred.ServiceUrl.length > 0) { - return; - } - - // Skip if already in filtered list - if (filtered.some(f => f.Id === cred.Id)) { - return; - } - - // Check page title match - if (titleWords.length > 0 && cred.ServiceName) { - const credNameWords = cred.ServiceName.toLowerCase() - .split(/\s+/) - .filter(word => word.length > 3 && !CombinedStopWords.has(word)); - const hasTitleMatch = titleWords.some(word => - credNameWords.some(credWord => credWord.includes(word)) - ); - - if (hasTitleMatch) { - filtered.push({ ...cred, priority: 5 }); - } - } - }); - - // Sort by priority and then take unique credentials + // Sort by priority and return unique credentials (max 3) const uniqueCredentials = Array.from( - new Map(filtered - .sort((a, b) => a.priority - b.priority) - .map(cred => [cred.Id, cred])) - .values() + 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/apps/browser-extension/src/entrypoints/contentScript/Popup.ts b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts index 9cf8781c6..ec126fcf0 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -1,9 +1,9 @@ import { sendMessage } from 'webext-bridge/content-script'; -import { filterCredentials } from '@/entrypoints/contentScript/Filter'; +import { filterCredentials, AutofillMatchingMode } from '@/entrypoints/contentScript/Filter'; import { fillCredential } from '@/entrypoints/contentScript/Form'; -import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, LAST_CUSTOM_EMAIL_KEY, LAST_CUSTOM_USERNAME_KEY } from '@/utils/Constants'; +import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, LAST_CUSTOM_EMAIL_KEY, LAST_CUSTOM_USERNAME_KEY, AUTOFILL_MATCHING_MODE_KEY } from '@/utils/Constants'; import { CreateIdentityGenerator } from '@/utils/dist/shared/identity-generator'; import type { Credential } from '@/utils/dist/shared/models/vault'; import { CreatePasswordGenerator, PasswordGenerator, PasswordSettings } from '@/utils/dist/shared/password-generator'; @@ -187,10 +187,14 @@ export async function createAutofillPopup(input: HTMLInputElement, credentials: credentials = []; } + // Load autofill matching mode setting + const matchingMode = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT; + const filteredCredentials = filterCredentials( credentials, window.location.href, - document.title + document.title, + matchingMode ); updatePopupContent(filteredCredentials, credentialList, input, rootContainer, noMatchesText); @@ -363,8 +367,8 @@ export async function createAutofillPopup(input: HTMLInputElement, credentials: // Handle search input. let searchTimeout: NodeJS.Timeout | null = null; - searchInput.addEventListener('input', () => { - handleSearchInput(searchInput, credentials, rootContainer, searchTimeout, credentialList, input, noMatchesText); + searchInput.addEventListener('input', async () => { + await handleSearchInput(searchInput, credentials, rootContainer, searchTimeout, credentialList, input, noMatchesText); }); // Close button @@ -573,7 +577,7 @@ export async function createVaultLockedPopup(input: HTMLInputElement, rootContai /** * Handle popup search input by filtering credentials based on the search term. */ -function handleSearchInput(searchInput: HTMLInputElement, credentials: Credential[], rootContainer: HTMLElement, searchTimeout: NodeJS.Timeout | null, credentialList: HTMLElement | null, input: HTMLInputElement, noMatchesText?: string) : void { +async function handleSearchInput(searchInput: HTMLInputElement, credentials: Credential[], rootContainer: HTMLElement, searchTimeout: NodeJS.Timeout | null, credentialList: HTMLElement | null, input: HTMLInputElement, noMatchesText?: string) : Promise { if (searchTimeout) { clearTimeout(searchTimeout); } @@ -584,11 +588,15 @@ function handleSearchInput(searchInput: HTMLInputElement, credentials: Credentia let filteredCredentials; if (searchTerm === '') { + // Load autofill matching mode setting + const matchingMode = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT; + // If search is empty, use original URL-based filtering filteredCredentials = filterCredentials( uniqueCredentials, window.location.href, - document.title + document.title, + matchingMode ).sort((a, b) => { // First compare by service name const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? ''); 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 22e0f5d87..42557d1d2 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/__tests__/Filter.test.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/__tests__/Filter.test.ts @@ -267,6 +267,18 @@ describe('Filter - Credential URL Matching', () => { expect(matches).toHaveLength(0); }); + // [#18] - Ensure only full words are matched + it('should not match on string part of word', () => { + const matches = filterCredentials( + testCredentials, + 'Title | Express Yourself | Description', + '' + ); + + // The string above should not match "AliExpress" service name + expect(matches).toHaveLength(0); + }); + /** * 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. @@ -284,6 +296,7 @@ describe('Filter - Credential URL Matching', () => { createTestCredential('Subdomain Example', 'https://app.example.com', 'user@example.com'), createTestCredential('Title Only newyorktimes', '', ''), createTestCredential('Bank Account', 'https://secure-bank.com', 'user@bank.com'), + createTestCredential('AliExpress', 'https://aliexpress.com', 'user@aliexpress.com'), ]; } diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx index 7443fef76..0e5a948bf 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Settings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { sendMessage } from 'webext-bridge/popup'; +import { AutofillMatchingMode } from '@/entrypoints/contentScript/Filter'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import HelpModal from '@/entrypoints/popup/components/HelpModal'; import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; @@ -15,7 +16,7 @@ import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility'; import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; import { AppInfo } from '@/utils/AppInfo'; -import { DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, GLOBAL_CONTEXT_MENU_ENABLED_KEY, TEMPORARY_DISABLED_SITES_KEY, CLIPBOARD_CLEAR_TIMEOUT_KEY, AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants'; +import { DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, GLOBAL_CONTEXT_MENU_ENABLED_KEY, TEMPORARY_DISABLED_SITES_KEY, CLIPBOARD_CLEAR_TIMEOUT_KEY, AUTO_LOCK_TIMEOUT_KEY, AUTOFILL_MATCHING_MODE_KEY } from '@/utils/Constants'; import { storage, browser } from "#imports"; @@ -52,6 +53,7 @@ const Settings: React.FC = () => { }); const [clipboardTimeout, setClipboardTimeout] = useState(10); const [autoLockTimeout, setAutoLockTimeout] = useState(0); + const [autofillMatchingMode, setAutofillMatchingMode] = useState(AutofillMatchingMode.DEFAULT); /** * Get current tab in browser. @@ -134,6 +136,10 @@ const Settings: React.FC = () => { const autoLockTimeoutValue = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0; setAutoLockTimeout(autoLockTimeoutValue); + // Load autofill matching mode + const matchingModeValue = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT; + setAutofillMatchingMode(matchingModeValue); + setSettings({ disabledUrls, temporaryDisabledUrls: cleanedTemporaryDisabledUrls, @@ -258,6 +264,14 @@ const Settings: React.FC = () => { setAutoLockTimeout(timeout); }; + /** + * Set autofill matching mode. + */ + const setAutofillMatchingModeSetting = async (mode: AutofillMatchingMode) : Promise => { + await storage.setItem(AUTOFILL_MATCHING_MODE_KEY, mode); + setAutofillMatchingMode(mode); + }; + /** * Open keyboard shortcuts configuration page. */ @@ -428,6 +442,28 @@ const Settings: React.FC = () => { )} + {/* Autofill Matching Settings Section */} +
+

{t('settings.autofillMatching')}

+
+
+
+

{t('settings.autofillMatchingMode')}

+

{t('settings.autofillMatchingModeDescription')}

+ +
+
+
+
+ {/* Security Settings Section */}

{t('settings.security')}

diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index bc1ba1acd..96a3fc456 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -299,6 +299,12 @@ "enabled": "Enabled", "disabled": "Disabled", "rightClickContextMenu": "Right-click context menu", + "autofillMatching": "Autofill Matching", + "autofillMatchingMode": "Autofill matching mode", + "autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.", + "autofillMatchingDefault": "URL + subdomain + name wildcard", + "autofillMatchingUrlSubdomain": "URL + subdomain", + "autofillMatchingUrlExact": "Exact URL domain only", "siteSpecificSettings": "Site-Specific Settings", "autofillPopupOn": "Autofill popup on: ", "enabledForThisSite": "Enabled for this site", diff --git a/apps/browser-extension/src/utils/Constants.ts b/apps/browser-extension/src/utils/Constants.ts index b7944232f..c1166f7c0 100644 --- a/apps/browser-extension/src/utils/Constants.ts +++ b/apps/browser-extension/src/utils/Constants.ts @@ -6,6 +6,7 @@ export const VAULT_LOCKED_DISMISS_UNTIL_KEY = 'local:aliasvault_vault_locked_dis export const TEMPORARY_DISABLED_SITES_KEY = 'local:aliasvault_temporary_disabled_sites'; export const CLIPBOARD_CLEAR_TIMEOUT_KEY = 'local:aliasvault_clipboard_clear_timeout'; export const AUTO_LOCK_TIMEOUT_KEY = 'local:aliasvault_auto_lock_timeout'; +export const AUTOFILL_MATCHING_MODE_KEY = 'local:aliasvault_autofill_matching_mode'; // TODO: store these settings in the actual vault when updating the datamodel for roadmap v1.0. export const LAST_CUSTOM_EMAIL_KEY = 'local:aliasvault_last_custom_email';