Update browser extension form detector to better detect password fields (#2016)

This commit is contained in:
Leendert de Borst
2026-05-17 11:22:18 +02:00
committed by Leendert de Borst
parent ae9f151f14
commit 8ddc2e15e1
5 changed files with 159 additions and 8 deletions

View File

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

View File

@@ -720,11 +720,18 @@ export class FormDetector {
}
}
// If email type is explicitly requested, prefer actual <input type="email">
if (types.includes('email') && type === 'email') {
matches.push({ input: input as HTMLInputElement, score: -1 });
continue;
}
// If password type is explicitly requested, prefer actual <input type="password">
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,

View File

@@ -259,4 +259,96 @@ describe('FormDetector English tests', () => {
});
});
/*
* Regression: a field declared as <input type="password"> 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);
});
});
});

View File

@@ -0,0 +1,26 @@
<!--
Login form with a password field whose id/name/placeholder do NOT contain
the strings "password", "pwd" or "pass". The HTML standard type="password"
attribute is the only signal that this is a password input.
-->
<html>
<body>
<form id="login-form" action="/login" method="post">
<div>
<label for="loginUser">Username</label>
<input type="text" id="loginUser" name="user" autocomplete="off">
</div>
<div>
<label for="loginPin">Login code</label>
<input
type="password"
name="pin"
id="loginPin"
class="inputs input-block form-control input-lg ng-pristine ng-valid"
maxlength="5"
autocomplete="off">
</div>
<button type="submit">Sign in</button>
</form>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!--
Some sites mask OTP/2FA inputs with type="password" instead of the more
appropriate type="text"/inputmode="numeric". The TOTP-related keywords in
the id/name should cause the password detector to skip the field via the
password entry's exclude list.
-->
<html>
<body>
<form id="2fa-form" action="/verify" method="post">
<div>
<label for="otp">Enter your 6-digit verification code</label>
<input
type="password"
name="otp_code"
id="otp"
maxlength="6"
autocomplete="off">
</div>
<button type="submit">Verify</button>
</form>
</body>
</html>