Add TOTP field detection to FormDetector (#1634)

This commit is contained in:
Leendert de Borst
2026-02-17 21:36:03 +01:00
committed by Leendert de Borst
parent 0735e257ec
commit 0cdfe5f4d9
6 changed files with 119 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ export type FieldPatterns = {
birthDateDay: string[];
birthDateMonth: string[];
birthDateYear: string[];
totp: string[];
}
/**
@@ -48,7 +49,8 @@ export const EnglishFieldPatterns: FieldPatterns = {
gender: ['gender', 'sex'],
birthDateDay: ['-day', 'birthdate_d', 'birthdayday', '_day', 'day'],
birthDateMonth: ['-month', 'birthdate_m', 'birthdaymonth', '_month', 'month'],
birthDateYear: ['-year', 'birthdate_y', 'birthdayyear', '_year', 'year']
birthDateYear: ['-year', 'birthdate_y', 'birthdayyear', '_year', 'year'],
totp: ['totp', 'otp', 'one-time', 'onetime', 'token', 'authenticator', '2fa', 'twofa', 'two-factor', 'mfa', 'security-code', 'auth-code', 'passcode', 'pin-code', 'pincode']
};
/**
@@ -123,7 +125,8 @@ export const DutchFieldPatterns: FieldPatterns = {
gender: ['geslacht', 'aanhef'],
birthDateDay: ['dag'],
birthDateMonth: ['maand'],
birthDateYear: ['jaar']
birthDateYear: ['jaar'],
totp: ['verificatiecode', 'eenmalig', 'authenticatie', 'tweefactor', 'beveiligingscode']
};
/**
@@ -204,7 +207,8 @@ export const CombinedFieldPatterns: FieldPatterns = {
gender: [...new Set([...EnglishFieldPatterns.gender, ...DutchFieldPatterns.gender])],
birthDateDay: [...new Set([...EnglishFieldPatterns.birthDateDay, ...DutchFieldPatterns.birthDateDay])],
birthDateMonth: [...new Set([...EnglishFieldPatterns.birthDateMonth, ...DutchFieldPatterns.birthDateMonth])],
birthDateYear: [...new Set([...EnglishFieldPatterns.birthDateYear, ...DutchFieldPatterns.birthDateYear])]
birthDateYear: [...new Set([...EnglishFieldPatterns.birthDateYear, ...DutchFieldPatterns.birthDateYear])],
totp: [...new Set([...EnglishFieldPatterns.totp, ...DutchFieldPatterns.totp])]
};
/**

View File

@@ -42,8 +42,8 @@ export class FormDetector {
return false;
}
// Check if the wrapper contains a password or likely username field before processing.
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper)) {
// Check if the wrapper contains a password, likely username field, or TOTP field before processing.
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper) || this.containsTotpField(formWrapper)) {
return true;
}
@@ -871,6 +871,66 @@ export class FormDetector {
return false;
}
/**
* Check if a form contains a TOTP/2FA field.
*/
private containsTotpField(wrapper: HTMLElement): boolean {
const totpField = this.findTotpField(wrapper as HTMLFormElement | null);
return totpField !== null && this.isElementVisible(totpField);
}
/**
* 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 {
// First try pattern-based detection
const candidates = this.findAllInputFields(
form,
CombinedFieldPatterns.totp,
['text', 'number']
);
// Filter out parent-child duplicates
const filteredCandidates = this.filterOutNestedDuplicates(candidates);
if (filteredCandidates.length > 0) {
return filteredCandidates[0];
}
// Additional heuristics for TOTP fields that may not match patterns
const allInputs = form
? Array.from(form.querySelectorAll<HTMLInputElement>('input'))
: Array.from(this.document.querySelectorAll<HTMLInputElement>('input'));
for (const input of allInputs) {
if (!this.isElementVisible(input)) {
continue;
}
// Check for autocomplete="one-time-code"
const autocomplete = input.getAttribute('autocomplete')?.toLowerCase() ?? '';
if (autocomplete === 'one-time-code') {
return input;
}
// Check for maxLength=6 combined with inputmode="numeric"
const maxLength = input.maxLength;
const inputMode = input.getAttribute('inputmode');
if (maxLength === 6 && inputMode === 'numeric') {
return input;
}
// Check for numeric pattern attribute with length constraint
const pattern = input.getAttribute('pattern');
if (pattern && /^\[0-9\]/.test(pattern) && maxLength === 6) {
return input;
}
}
return null;
}
/**
* Check if a form contains a likely username or email field.
*/
@@ -964,6 +1024,12 @@ export class FormDetector {
return 'email';
}
// Check if any of the elements is a TOTP field
const totpField = this.findTotpField(formWrapper as HTMLFormElement | null);
if (totpField && elementsToCheck.includes(totpField)) {
return 'totp';
}
return null;
}
@@ -1030,6 +1096,11 @@ 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,
@@ -1041,7 +1112,8 @@ export class FormDetector {
firstNameField,
lastNameField,
birthdateField,
genderField
genderField,
totpField
};
}
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { FormField, testField } from './TestUtils';
describe('FormDetector TOTP tests', () => {
it('contains tests for TOTP field detection', () => {
/**
* This test suite uses testField() helper function
* to test TOTP/2FA field detection for various forms.
* The actual test implementations are in the helper functions.
* This test is just to ensure the test suite is working and to satisfy the linter.
*/
expect(true).toBe(true);
});
describe('English TOTP form 1 detection', () => {
const htmlFile = 'en-totp-form1.html';
testField(FormField.Totp, 'otp', htmlFile);
});
});

View File

@@ -26,7 +26,8 @@ export enum FormField {
Gender = 'gender',
GenderMale = 'genderMale',
GenderFemale = 'genderFemale',
GenderOther = 'genderOther'
GenderOther = 'genderOther',
Totp = 'totp'
}
/**
@@ -199,7 +200,8 @@ export const createMockFormFields = (document: Document): FormFields => ({
genderField: {
type: 'select',
field: document.createElement('select')
}
},
totpField: null
});
/**

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>TOTP Form 1</title>
</head>
<body>
<div data-v-f878e761="" class="form"><form data-v-f878e761=""><h1 data-v-f878e761="" class="h3 mb-3 fw-normal"></h1><!----><!----><div data-v-f878e761=""><div data-v-f878e761="" class="form-floating mt-3"><input data-v-f878e761="" id="otp" type="text" maxlength="6" class="form-control" placeholder="123456" autocomplete="off" required="" data-av-autocomplete="one-time-code" data-av-field-type="totp"><label data-v-f878e761="" for="otp">Token</label></div></div><div data-v-f878e761="" class="form-check mb-3 mt-3 d-flex justify-content-center pe-4"><div data-v-f878e761="" class="form-check"><input data-v-f878e761="" id="remember" type="checkbox" class="form-check-input" value="remember-me"><label data-v-f878e761="" class="form-check-label" for="remember">Remember me</label></div></div><button data-v-f878e761="" class="w-100 btn btn-primary" type="submit">Login</button><!----></form></div>
</body>
</html>

View File

@@ -30,4 +30,7 @@ export type FormFields = {
other: HTMLInputElement | null;
};
};
// TOTP/2FA field for one-time codes
totpField: HTMLInputElement | null;
}