Add autofill matching mode configurable setting to browser extension (#1142)

This commit is contained in:
Leendert de Borst
2025-08-25 17:18:06 +02:00
committed by Leendert de Borst
parent 047b0723b3
commit f86400fa50
6 changed files with 197 additions and 77 deletions

View File

@@ -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);
}

View File

@@ -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<void> {
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 ?? '');

View File

@@ -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'),
];
}

View File

@@ -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<number>(10);
const [autoLockTimeout, setAutoLockTimeout] = useState<number>(0);
const [autofillMatchingMode, setAutofillMatchingMode] = useState<AutofillMatchingMode>(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<void> => {
await storage.setItem(AUTOFILL_MATCHING_MODE_KEY, mode);
setAutofillMatchingMode(mode);
};
/**
* Open keyboard shortcuts configuration page.
*/
@@ -428,6 +442,28 @@ const Settings: React.FC = () => {
</section>
)}
{/* Autofill Matching Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.autofillMatching')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.autofillMatchingMode')}</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.autofillMatchingModeDescription')}</p>
<select
value={autofillMatchingMode}
onChange={(e) => setAutofillMatchingModeSetting(e.target.value as AutofillMatchingMode)}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
>
<option value={AutofillMatchingMode.DEFAULT}>{t('settings.autofillMatchingDefault')}</option>
<option value={AutofillMatchingMode.URL_SUBDOMAIN}>{t('settings.autofillMatchingUrlSubdomain')}</option>
<option value={AutofillMatchingMode.URL_EXACT}>{t('settings.autofillMatchingUrlExact')}</option>
</select>
</div>
</div>
</div>
</section>
{/* Security Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.security')}</h3>

View File

@@ -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",

View File

@@ -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';