mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Add autofill matching mode configurable setting to browser extension (#1142)
This commit is contained in:
committed by
Leendert de Borst
parent
047b0723b3
commit
f86400fa50
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user