From 8ddc2e15e10384aa637fdd75513abaee7001ac6f Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 17 May 2026 11:22:18 +0200 Subject: [PATCH] Update browser extension form detector to better detect password fields (#2016) --- .../src/utils/formDetector/FieldPatterns.ts | 5 +- .../src/utils/formDetector/FormDetector.ts | 22 +++-- .../__tests__/FormDetector.en.test.ts | 92 +++++++++++++++++++ .../en-login-form-typed-password.html | 26 ++++++ .../en-totp-masked-as-password.html | 22 +++++ 5 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-login-form-typed-password.html create mode 100644 apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-totp-masked-as-password.html diff --git a/apps/browser-extension/src/utils/formDetector/FieldPatterns.ts b/apps/browser-extension/src/utils/formDetector/FieldPatterns.ts index dc4046538..1492c21a8 100644 --- a/apps/browser-extension/src/utils/formDetector/FieldPatterns.ts +++ b/apps/browser-extension/src/utils/formDetector/FieldPatterns.ts @@ -77,7 +77,10 @@ export const EnglishFieldPatterns: FieldPatterns = { lastName: { include: ['lastname', 'last-name', 'last_name', 'lname', 'surname', 'family-name'] }, email: { include: ['email', 'mail', 'emailaddress'] }, emailConfirm: { include: ['confirm', 'verification', 'repeat', 'retype', 'verify', 'email2'] }, - password: { include: ['password', 'pwd', 'pass'] }, + password: { + include: ['password', 'pwd', 'pass'], + exclude: ['otp', 'totp', 'tfa', '2fa', 'mfa', 'twofa', 'authenticator', 'one-time', 'onetime', 'verification-code', 'verificationcode', 'two-factor', 'second-factor', 'auth-code', 'security-code', 'six-digit'] + }, birthdate: { include: ['birthdate', 'birth-date', 'dob', 'date-of-birth'] }, gender: { include: ['gender', 'sex'] }, birthDateDay: { include: ['-day', 'birthdate_d', 'birthdayday', '_day', 'day'] }, diff --git a/apps/browser-extension/src/utils/formDetector/FormDetector.ts b/apps/browser-extension/src/utils/formDetector/FormDetector.ts index bcb64c697..af70d5561 100644 --- a/apps/browser-extension/src/utils/formDetector/FormDetector.ts +++ b/apps/browser-extension/src/utils/formDetector/FormDetector.ts @@ -720,11 +720,18 @@ export class FormDetector { } } + // If email type is explicitly requested, prefer actual if (types.includes('email') && type === 'email') { matches.push({ input: input as HTMLInputElement, score: -1 }); continue; } + // If password type is explicitly requested, prefer actual + if (types.includes('password') && type === 'password') { + matches.push({ input: input as HTMLInputElement, score: -1 }); + continue; + } + /** * Check autocomplete attribute for direct field type matching. * First check our custom data-av-autocomplete attribute (set by AliasVault when disabling @@ -1372,7 +1379,7 @@ export class FormDetector { * Find a TOTP/2FA input field in the form. * Uses pattern matching and heuristics specific to TOTP fields. */ - private findTotpField(form: HTMLFormElement | null): HTMLInputElement | null { + private findTotpField(form: HTMLFormElement | null, excludeElements: HTMLInputElement[] = []): HTMLInputElement | null { // Check if this is an email verification form (not TOTP/2FA) if (this.isEmailVerificationForm()) { return null; @@ -1382,7 +1389,8 @@ export class FormDetector { const candidates = this.findAllInputFields( form, CombinedFieldPatterns.totp, - ['text', 'number'] + ['text', 'number', 'password'], + excludeElements ); // Filter out parent-child duplicates @@ -1607,6 +1615,11 @@ export class FormDetector { if (passwordFields.confirm) { detectedFields.push(passwordFields.confirm); } + + const totpField = this.findTotpField(wrapper as HTMLFormElement | null, detectedFields); + if (totpField) { + detectedFields.push(totpField); + } const usernameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text'], detectedFields, checkVisibility); if (usernameField) { @@ -1657,11 +1670,6 @@ export class FormDetector { detectedFields.push(genderField.field as HTMLInputElement); } - const totpField = this.findTotpField(wrapper as HTMLFormElement | null); - if (totpField) { - detectedFields.push(totpField); - } - return { form: wrapper as HTMLFormElement, emailField: emailFields.primary, diff --git a/apps/browser-extension/src/utils/formDetector/__tests__/FormDetector.en.test.ts b/apps/browser-extension/src/utils/formDetector/__tests__/FormDetector.en.test.ts index 112c3fc23..3dec545da 100644 --- a/apps/browser-extension/src/utils/formDetector/__tests__/FormDetector.en.test.ts +++ b/apps/browser-extension/src/utils/formDetector/__tests__/FormDetector.en.test.ts @@ -259,4 +259,96 @@ describe('FormDetector English tests', () => { }); }); + /* + * Regression: a field declared as must be detected + * as a password field even when its id/name/placeholder don't include the + * strings "password"/"pwd"/"pass" (e.g. intranet forms using "pin", + * "loginPin", etc.). The HTML standard type attribute is an unambiguous + * signal that should be respected. + */ + describe('English login form with type="password" but no password keyword in id/name', () => { + const htmlFile = 'en-login-form-typed-password.html'; + + testField(FormField.Username, 'loginUser', htmlFile); + testField(FormField.Password, 'loginPin', htmlFile); + + it('should detect the form as a login form', () => { + const dom = createTestDom(htmlFile); + const document = dom.window.document; + const formDetector = new FormDetector(document); + expect(formDetector.containsLoginForm()).toBe(true); + }); + + it('should trigger autofill when clicking the type="password" field', () => { + const dom = createTestDom(htmlFile); + const document = dom.window.document; + + const pinInput = document.getElementById('loginPin'); + const formDetector = new FormDetector(document, pinInput as HTMLElement); + + expect(formDetector.isAutofillTriggerableField()).toBe(true); + }); + + it('should expose the type="password" field as the primary password field', () => { + const dom = createTestDom(htmlFile); + const document = dom.window.document; + + const pinInput = document.getElementById('loginPin'); + const formDetector = new FormDetector(document, pinInput as HTMLElement); + const form = formDetector.getForm(); + + expect(form).toBeTruthy(); + expect(form?.passwordField).toBeTruthy(); + expect(form?.passwordField?.id).toBe('loginPin'); + expect(form?.passwordField?.type).toBe('password'); + expect(form?.passwordConfirmField).toBeFalsy(); + }); + }); + + /* + * Counterpart to the test above: when type="password" is (mis)used to mask + * an OTP/2FA input, the password detector must NOT classify it as a + * credential password (otherwise the master password would be autofilled + * into a TOTP field), and the TOTP detector should pick it up so the + * user still gets a meaningful autofill experience. + */ + describe('English form with type="password" masked TOTP input', () => { + const htmlFile = 'en-totp-masked-as-password.html'; + + it('should NOT classify the type="password" OTP field as a credential password', () => { + const dom = createTestDom(htmlFile); + const document = dom.window.document; + + const otpInput = document.getElementById('otp'); + const formDetector = new FormDetector(document, otpInput as HTMLElement); + const form = formDetector.getForm(); + + expect(form?.passwordField).toBeFalsy(); + expect(form?.passwordConfirmField).toBeFalsy(); + }); + + it('should classify the type="password" OTP field as a TOTP field', () => { + const dom = createTestDom(htmlFile); + const document = dom.window.document; + + const otpInput = document.getElementById('otp'); + const formDetector = new FormDetector(document, otpInput as HTMLElement); + const form = formDetector.getForm(); + + expect(form?.totpField).toBeTruthy(); + expect(form?.totpField?.id).toBe('otp'); + expect(form?.totpField?.type).toBe('password'); + }); + + it('should trigger autofill when clicking the masked OTP field', () => { + const dom = createTestDom(htmlFile); + const document = dom.window.document; + + const otpInput = document.getElementById('otp'); + const formDetector = new FormDetector(document, otpInput as HTMLElement); + + expect(formDetector.isAutofillTriggerableField()).toBe(true); + }); + }); + }); diff --git a/apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-login-form-typed-password.html b/apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-login-form-typed-password.html new file mode 100644 index 000000000..fd5837271 --- /dev/null +++ b/apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-login-form-typed-password.html @@ -0,0 +1,26 @@ + + + +
+
+ + +
+
+ + +
+ +
+ + diff --git a/apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-totp-masked-as-password.html b/apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-totp-masked-as-password.html new file mode 100644 index 000000000..28a95133c --- /dev/null +++ b/apps/browser-extension/src/utils/formDetector/__tests__/test-forms/en-totp-masked-as-password.html @@ -0,0 +1,22 @@ + + + +
+
+ + +
+ +
+ +