Share inline menu qualification service implementation of keyword matching with autofill service (#19263)

This commit is contained in:
blackwood 2026-04-02 14:56:59 -04:00 committed by GitHub
parent 5010c47864
commit 6380248ce9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 444 additions and 540 deletions

View File

@ -9,12 +9,12 @@ import { DomQueryService } from "../services/dom-query.service";
import InsertAutofillContentService from "../services/insert-autofill-content.service";
import {
elementIsInputElement,
getSubmitButtonKeywordsSet,
nodeIsButtonElement,
nodeIsFormElement,
nodeIsTypeSubmitElement,
sendExtensionMessage,
} from "../utils";
import { getSubmitButtonKeywordsSet } from "../utils/qualification";
(function (globalContext) {
const domQueryService = new DomQueryService();

View File

@ -1,14 +1,6 @@
import AutofillField from "../../models/autofill-field";
import AutofillPageDetails from "../../models/autofill-page-details";
export type AutofillKeywordsMap = WeakMap<
AutofillField,
{
keywordsSet: Set<string>;
stringValue: string;
}
>;
export type SubmitButtonKeywordsMap = WeakMap<HTMLElement, string>;
export interface InlineMenuFieldQualificationService {

View File

@ -68,7 +68,56 @@ export class AutoFillConstants {
"create",
];
static readonly NewsletterFormNames: string[] = ["newsletter"];
/**
* Field-level keywords indicating account creation or registration context.
* Broader than {@link RegistrationKeywords}, which is used at the form level.
*/
static readonly AccountCreationFieldKeywords: string[] = [
"register",
"registration",
"create password",
"create a password",
"create an account",
"create account password",
"create user password",
"confirm password",
"confirm account password",
"confirm user password",
"new user",
"new email",
"new e-mail",
"new password",
"new-password",
"neuer benutzer",
"neues passwort",
"neue e-mail",
"pwdcheck",
];
/**
* Field-level keywords indicating a password update or change context, as distinguished
* from a new account creation or initial login context.
*/
static readonly UpdatePasswordFieldKeywords: string[] = [
"update password",
"change password",
"current password",
"kennwort ändern",
];
/**
* Form-level keywords indicating a non-login context such as newsletter signup or
* subscription forms. Used to exclude fields within these forms from login autofill.
*/
static readonly NonLoginFormKeywords: string[] = [
"newsletter",
// @TODO expand list thoughtfully
// consider possible collisions with login forms
// consider using a "maybe" check
// "subscribe",
// "subscription",
// "unsubscribe",
];
static readonly FieldIgnoreList: string[] = ["captcha", "findanything", "forgot"];
@ -397,6 +446,7 @@ export class IdentityAutoFillConstants {
"label-top",
"data-recurly",
"accountCreationFieldType",
"type",
];
static readonly FullNameFieldNames: string[] = ["name", "full-name", "your-name"];

View File

@ -59,6 +59,7 @@ import {
createGenerateFillScriptOptionsMock,
} from "../spec/autofill-mocks";
import { flushPromises, triggerTestFailure } from "../spec/testing-utils";
import * as qualification from "../utils/qualification";
import {
AutoFillOptions,
@ -1832,7 +1833,7 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "inUntrustedIframe");
jest.spyOn(AutofillService, "loadPasswordFields");
jest.spyOn(autofillService as any, "findUsernameField");
jest.spyOn(AutofillService, "fieldIsFuzzyMatch");
jest.spyOn(qualification, "fieldContainsKeyword");
jest.spyOn(AutofillService, "fillByOpid");
jest.spyOn(AutofillService, "setFillScriptForFocus");
@ -1846,7 +1847,7 @@ describe("AutofillService", () => {
expect(autofillService["inUntrustedIframe"]).not.toHaveBeenCalled();
expect(AutofillService.loadPasswordFields).not.toHaveBeenCalled();
expect(autofillService["findUsernameField"]).not.toHaveBeenCalled();
expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalled();
expect(qualification.fieldContainsKeyword).not.toHaveBeenCalled();
expect(AutofillService.fillByOpid).not.toHaveBeenCalled();
expect(AutofillService.setFillScriptForFocus).not.toHaveBeenCalled();
expect(value).toBeNull();
@ -2384,11 +2385,11 @@ describe("AutofillService", () => {
totpFieldView,
nonViewableFieldView,
];
jest.spyOn(AutofillService, "fieldIsFuzzyMatch");
jest.spyOn(qualification, "fieldContainsKeyword");
jest.spyOn(AutofillService, "fillByOpid");
});
it("will attempt to fuzzy match a username to a viewable text, email or tel field if no password fields are found and the username fill is not being skipped", async () => {
it("will attempt to keyword match a username to a viewable text, email or tel field if no password fields are found and the username fill is not being skipped", async () => {
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
@ -2396,35 +2397,40 @@ describe("AutofillService", () => {
options,
);
expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).toHaveBeenCalledWith(
usernameField,
AutoFillConstants.UsernameFieldNames,
);
expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).toHaveBeenCalledWith(
emailField,
AutoFillConstants.UsernameFieldNames,
);
expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).toHaveBeenCalledWith(
telephoneField,
AutoFillConstants.UsernameFieldNames,
);
expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).toHaveBeenCalledWith(
totpField,
AutoFillConstants.UsernameFieldNames,
);
expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).not.toHaveBeenCalledWith(
nonViewableField,
AutoFillConstants.UsernameFieldNames,
);
expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(1);
expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(2);
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
fillScript,
usernameField,
options.cipher.login.username,
);
expect(AutofillService.fillByOpid).toHaveBeenCalledWith(
fillScript,
emailField,
options.cipher.login.username,
);
});
it("will not attempt to fuzzy match a username if the username fill is being skipped", async () => {
it("will not attempt to keyword match a username if the username fill is being skipped", async () => {
options.skipUsernameOnlyFill = true;
await autofillService["generateLoginFillScript"](
@ -2434,13 +2440,13 @@ describe("AutofillService", () => {
options,
);
expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).not.toHaveBeenCalledWith(
expect.anything(),
AutoFillConstants.UsernameFieldNames,
);
});
it("will attempt to fuzzy match a totp field if totp autofill is allowed", async () => {
it("will attempt to keyword match a totp field if totp autofill is allowed", async () => {
options.allowTotpAutofill = true;
await autofillService["generateLoginFillScript"](
@ -2450,13 +2456,13 @@ describe("AutofillService", () => {
options,
);
expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledWith(
expect(qualification.fieldContainsKeyword).toHaveBeenCalledWith(
expect.anything(),
AutoFillConstants.TotpFieldNames,
);
});
it("will not attempt to fuzzy match a totp field if totp autofill is not allowed", async () => {
it("will not attempt to keyword match a totp field if totp autofill is not allowed", async () => {
options.allowTotpAutofill = false;
jest.spyOn(autofillService as any, "findMatchingFieldIndex");
@ -2624,7 +2630,6 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "inUntrustedIframe");
jest.spyOn(AutofillService, "loadPasswordFields");
jest.spyOn(autofillService as any, "findUsernameField");
jest.spyOn(AutofillService, "fieldIsFuzzyMatch");
jest.spyOn(AutofillService, "fillByOpid");
jest.spyOn(AutofillService, "setFillScriptForFocus");
@ -4534,7 +4539,6 @@ describe("AutofillService", () => {
});
pageDetails.fields = [usernameField, passwordField];
jest.spyOn(AutofillService, "forCustomFieldsOnly");
jest.spyOn(autofillService as any, "findMatchingFieldIndex");
});
it("returns null when passed a field that is a `span` element", () => {
@ -4543,7 +4547,6 @@ describe("AutofillService", () => {
const result = autofillService["findUsernameField"](pageDetails, field, false, false, false);
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(field);
expect(result).toBe(null);
});
@ -4694,7 +4697,7 @@ describe("AutofillService", () => {
expect(result).toBe(null);
});
it("returns the username field whose attributes most closely describe the username of the password field", () => {
it("returns the field in the same form whose attributes most closely describe a username, stopping early at that match", () => {
const usernameField2 = createAutofillFieldMock({
opid: "username-field-2",
type: "text",
@ -4706,7 +4709,7 @@ describe("AutofillService", () => {
opid: "username-field-3",
type: "text",
form: "validFormId",
elementNumber: 1,
elementNumber: 2,
});
passwordField.elementNumber = 3;
pageDetails.fields = [usernameField, usernameField2, usernameField3, passwordField];
@ -4719,12 +4722,10 @@ describe("AutofillService", () => {
false,
);
// usernameField2 matches username keywords and is in the same form, so it is
// returned early; usernameField3 (which has no username keywords) is never considered.
expect(result).toBe(usernameField2);
expect(autofillService["findMatchingFieldIndex"]).toHaveBeenCalledTimes(2);
expect(autofillService["findMatchingFieldIndex"]).not.toHaveBeenCalledWith(
usernameField3,
AutoFillConstants.UsernameFieldNames,
);
expect(result).not.toBe(usernameField3);
});
});
@ -4751,7 +4752,6 @@ describe("AutofillService", () => {
pageDetails.fields = [passwordField, totpField];
jest.spyOn(AutofillService, "forCustomFieldsOnly");
jest.spyOn(autofillService as any, "findMatchingFieldIndex");
jest.spyOn(AutofillService, "fieldIsFuzzyMatch");
});
it("returns null when passed a field that is a `span` element", () => {
@ -5099,107 +5099,6 @@ describe("AutofillService", () => {
});
});
describe("fieldIsFuzzyMatch", () => {
let field: AutofillField;
const fieldProperties = [
"htmlID",
"htmlName",
"label-aria",
"label-tag",
"label-top",
"label-left",
"placeholder",
];
beforeEach(() => {
field = createAutofillFieldMock();
jest.spyOn(AutofillService, "hasValue");
jest.spyOn(AutofillService as any, "fuzzyMatch");
});
it("returns false if the field properties do not have any values", () => {
fieldProperties.forEach((property) => {
field[property] = "";
});
const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]);
expect(result).toBe(false);
});
it("returns false if the field properties do not have a value that is a fuzzy match", () => {
fieldProperties.forEach((property) => {
field[property] = "some-false-value";
const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]);
expect(AutofillService.hasValue).toHaveBeenCalled();
expect(AutofillService["fuzzyMatch"]).toHaveBeenCalledWith(
["some-value"],
"some-false-value",
);
expect(result).toBe(false);
field[property] = "";
});
});
it("returns true if the field property has a value that is a fuzzy match", () => {
fieldProperties.forEach((property) => {
field[property] = "some-value";
const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]);
expect(AutofillService.hasValue).toHaveBeenCalled();
expect(AutofillService["fuzzyMatch"]).toHaveBeenCalledWith(["some-value"], "some-value");
expect(result).toBe(true);
field[property] = "";
});
});
});
describe("fuzzyMatch", () => {
it("returns false if the passed options is null", () => {
const result = AutofillService["fuzzyMatch"](null, "some-value");
expect(result).toBe(false);
});
it("returns false if the passed options contains an empty array", () => {
const result = AutofillService["fuzzyMatch"]([], "some-value");
expect(result).toBe(false);
});
it("returns false if the passed value is null", () => {
const result = AutofillService["fuzzyMatch"](["some-value"], null);
expect(result).toBe(false);
});
it("returns false if the passed value is an empty string", () => {
const result = AutofillService["fuzzyMatch"](["some-value"], "");
expect(result).toBe(false);
});
it("returns false if the passed value is not present in the options array", () => {
const result = AutofillService["fuzzyMatch"](["some-value"], "some-other-value");
expect(result).toBe(false);
});
it("returns true if the passed value is within the options array", () => {
const result = AutofillService["fuzzyMatch"](
["some-other-value", "some-value"],
"some-value",
);
expect(result).toBe(true);
});
});
describe("hasValue", () => {
it("returns false if the passed string is null", () => {
const result = AutofillService.hasValue(null);

View File

@ -55,6 +55,7 @@ import { AutofillPort } from "../enums/autofill-port.enum";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import { fieldContainsKeyword, isNonLoginFormContext } from "../utils/qualification";
import {
AutoFillOptions,
@ -918,14 +919,12 @@ export default class AutofillService implements AutofillServiceInterface {
(focusedField.type === "text" ||
focusedField.type === "number" ||
focusedField.type === "tel") &&
(AutofillService.fieldIsFuzzyMatch(focusedField, [
(fieldContainsKeyword(focusedField, [
...AutoFillConstants.TotpFieldNames,
...AutoFillConstants.AmbiguousTotpFieldNames,
]) ||
focusedField.autoCompleteType === "one-time-code") &&
!AutofillService.fieldIsFuzzyMatch(focusedField, [
...AutoFillConstants.RecoveryCodeFieldNames,
]);
!fieldContainsKeyword(focusedField, [...AutoFillConstants.RecoveryCodeFieldNames]);
const focusedUsernameField =
focusedField &&
@ -1080,21 +1079,21 @@ export default class AutofillService implements AutofillServiceInterface {
const isTotpCandidate =
options.allowTotpAutofill &&
["number", "tel", "text"].some((t) => t === field.type) &&
!AutofillService.fieldIsFuzzyMatch(field, [...AutoFillConstants.RecoveryCodeFieldNames]);
!fieldContainsKeyword(field, [...AutoFillConstants.RecoveryCodeFieldNames]);
const isTotpField =
isTotpCandidate &&
(AutofillService.fieldIsFuzzyMatch(field, AutoFillConstants.TotpFieldNames) ||
(fieldContainsKeyword(field, AutoFillConstants.TotpFieldNames) ||
field.autoCompleteType === "one-time-code");
const maybeTotpField =
isTotpCandidate &&
AutofillService.fieldIsFuzzyMatch(field, AutoFillConstants.AmbiguousTotpFieldNames);
isTotpCandidate && fieldContainsKeyword(field, AutoFillConstants.AmbiguousTotpFieldNames);
const isUsernameField =
!options.skipUsernameOnlyFill &&
["email", "tel", "text"].some((t) => t === field.type) &&
AutofillService.fieldIsFuzzyMatch(field, AutoFillConstants.UsernameFieldNames);
fieldContainsKeyword(field, AutoFillConstants.UsernameFieldNames) &&
!isNonLoginFormContext(field, pageDetails);
// Reliable TOTP signals win unconditionally; username wins over ambiguous TOTP signals.
switch (true) {
@ -2423,7 +2422,7 @@ export default class AutofillService implements AutofillServiceInterface {
}
// We want to avoid treating TOTP fields as password fields
if (AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.TotpFieldNames)) {
if (fieldContainsKeyword(f, AutoFillConstants.TotpFieldNames)) {
return;
}
@ -2508,51 +2507,63 @@ export default class AutofillService implements AutofillServiceInterface {
canBeReadOnly: boolean,
withoutForm: boolean,
): AutofillField | null {
let usernameField: AutofillField | null = null;
let usernameFieldInSameForm: AutofillField | null = null;
let sameFormCandidate: AutofillField | null = null;
let bestCandidate: AutofillField | null = null;
for (let i = 0; i < pageDetails.fields.length; i++) {
const f = pageDetails.fields[i];
if (AutofillService.forCustomFieldsOnly(f)) {
const fieldsPrecedingPassword = pageDetails.fields.filter(
(f) => f.elementNumber < passwordField.elementNumber,
);
for (const field of fieldsPrecedingPassword) {
if (AutofillService.forCustomFieldsOnly(field)) {
continue;
}
if (f.elementNumber >= passwordField.elementNumber) {
break;
if (isNonLoginFormContext(field, pageDetails)) {
continue;
}
const includesUsernameFieldName =
this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1;
// only consider fields in same form if both have non-null form values
const isUsernameFieldType =
field.type === "text" || field.type === "email" || field.type === "tel";
if (!isUsernameFieldType) {
continue;
}
// Only consider fields with non-null form values as being in the same form;
// null forms are treated as separate
const isInSameForm =
f.form != null && passwordField.form != null && f.form === passwordField.form;
field.form != null && passwordField.form != null && field.form === passwordField.form;
// An email or tel field in the same form as the password field is likely a qualified
// candidate for autofill, even if visibility checks are unreliable
const isQualifiedUsernameField = isInSameForm && (f.type === "email" || f.type === "tel");
const includesUsernameKeyword = fieldContainsKeyword(
field,
AutoFillConstants.UsernameFieldNames,
);
if (
!f.disabled &&
(canBeReadOnly || !f.readonly) &&
(withoutForm || isInSameForm || includesUsernameFieldName) &&
(canBeHidden || f.viewable || isQualifiedUsernameField) &&
(f.type === "text" || f.type === "email" || f.type === "tel")
) {
// Prioritize fields in the same form as the password field
if (isInSameForm) {
usernameFieldInSameForm = f;
if (includesUsernameFieldName) {
return f;
}
} else {
usernameField = f;
// Email/tel fields in the same form are strong candidates even when visibility
// checks are unreliable
const isQualifiedByType = isInSameForm && (field.type === "email" || field.type === "tel");
const isAccessible = !field.disabled && (canBeReadOnly || !field.readonly);
const isVisible = canBeHidden || field.viewable || isQualifiedByType;
const isReachable = withoutForm || isInSameForm || includesUsernameKeyword;
if (!isAccessible || !isVisible || !isReachable) {
continue;
}
if (isInSameForm) {
sameFormCandidate = field;
// A same-form field explicitly named for username is the best possible match
if (includesUsernameKeyword) {
return field;
}
} else {
bestCandidate = field;
}
}
// Prefer username field in same form, fall back to any username field
return usernameFieldInSameForm || usernameField;
// Prefer a same-form candidate, fall back to any matching field
return sameFormCandidate || bestCandidate;
}
/**
@ -2592,11 +2603,11 @@ export default class AutofillService implements AutofillServiceInterface {
f.type === "number" ||
// sites will commonly use tel in order to get the digit pad against semantic recommendations
f.type === "tel") &&
AutofillService.fieldIsFuzzyMatch(f, [
fieldContainsKeyword(f, [
...AutoFillConstants.TotpFieldNames,
...AutoFillConstants.AmbiguousTotpFieldNames,
]) &&
!AutofillService.fieldIsFuzzyMatch(f, [...AutoFillConstants.RecoveryCodeFieldNames])
!fieldContainsKeyword(f, [...AutoFillConstants.RecoveryCodeFieldNames])
) {
totpField = f;
@ -2748,85 +2759,6 @@ export default class AutofillService implements AutofillServiceInterface {
return fieldVal.toLowerCase() === name;
}
/**
* Accepts a field and returns true if the field contains a
* value that matches any of the names in the provided list.
*
* Returns boolean and attr of value that was matched as a tuple if showMatch is set to true.
*
* @param {AutofillField} field
* @param {string[]} names
* @param {boolean} showMatch
* @returns {boolean | [boolean, { attr: string; value: string }?]}
*/
static fieldIsFuzzyMatch(
field: AutofillField,
names: string[],
showMatch: true,
): [boolean, { attr: string; value: string }?];
static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean;
static fieldIsFuzzyMatch(
field: AutofillField,
names: string[],
showMatch: boolean = false,
): boolean | [boolean, { attr: string; value: string }?] {
const attrs = [
"htmlID",
"htmlName",
"label-tag",
"placeholder",
"label-left",
"label-right",
"label-top",
"label-aria",
"dataSetValues",
];
for (const attr of attrs) {
const value = field[attr];
if (!AutofillService.hasValue(value)) {
continue;
}
if (AutofillService.fuzzyMatch(names, value)) {
return showMatch ? [true, { attr, value }] : true;
}
}
return showMatch ? [false] : false;
}
/**
* Accepts a list of options and a value and returns
* true if the value matches any of the options.
* @param {string[]} options
* @param {string} value
* @returns {boolean}
* @private
*/
private static fuzzyMatch(options: string[], value: string): boolean {
if (
options == null ||
options.length === 0 ||
value == null ||
typeof value !== "string" ||
value.length < 1
) {
return false;
}
value = value
.replace(/(?:\r\n|\r|\n)/g, "")
.trim()
.toLowerCase();
for (let i = 0; i < options.length; i++) {
if (value.indexOf(options[i]) > -1) {
return true;
}
}
return false;
}
/**
* Accepts a string and returns true if the
* string is not falsy and not empty.

View File

@ -1,9 +1,13 @@
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import { getSubmitButtonKeywordsSet, sendExtensionMessage } from "../utils";
import { sendExtensionMessage } from "../utils";
import {
fieldContainsKeyword,
getSubmitButtonKeywordsSet,
isNonLoginFormContext,
} from "../utils/qualification";
import {
AutofillKeywordsMap,
InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface,
SubmitButtonKeywordsMap,
} from "./abstractions/inline-menu-field-qualifications.service";
@ -14,7 +18,6 @@ import {
SubmitChangePasswordButtonNames,
SubmitLoginButtonNames,
} from "./autofill-constants";
import AutofillService from "./autofill.service";
export class InlineMenuFieldQualificationService implements InlineMenuFieldQualificationServiceInterface {
private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames);
@ -31,37 +34,8 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
private fieldIgnoreListString = AutoFillConstants.FieldIgnoreList.join(",");
private currentPasswordAutocompleteValue = "current-password";
private newPasswordAutoCompleteValue = "new-password";
private autofillFieldKeywordsMap: AutofillKeywordsMap = new WeakMap();
private submitButtonKeywordsMap: SubmitButtonKeywordsMap = new WeakMap();
private accountCreationFieldKeywords = [
"register",
"registration",
"create password",
"create a password",
"create an account",
"create account password",
"create user password",
"confirm password",
"confirm account password",
"confirm user password",
"new user",
"new email",
"new e-mail",
"new password",
"new-password",
"neuer benutzer",
"neues passwort",
"neue e-mail",
"pwdcheck",
];
private newEmailFieldKeywords = new Set(AutoFillConstants.NewEmailFieldKeywords);
private newsletterFormKeywords = new Set(AutoFillConstants.NewsletterFormNames);
private updatePasswordFieldKeywords = [
"update password",
"change password",
"current password",
"kennwort ändern",
];
private creditCardFieldKeywords = [
...new Set([
...CreditCardAutoFillConstants.CardHolderFieldNames,
@ -174,39 +148,6 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return false;
}
/**
* Validates the provided form to indicate if the form is related to newsletter registration.
*
* @param parentForm - The form to validate
*/
private isNewsletterForm(parentForm: any): boolean {
if (!parentForm) {
return false;
}
const matchFieldAttributeValues = [
parentForm.type,
parentForm.htmlName,
parentForm.htmlID,
parentForm.placeholder,
];
for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
const attrValue = matchFieldAttributeValues[attrIndex];
if (!attrValue || typeof attrValue !== "string") {
continue;
}
const attrValueLower = attrValue.toLowerCase();
for (const keyword of this.newsletterFormKeywords) {
if (attrValueLower.includes(keyword.toLowerCase())) {
return true;
}
}
}
return false;
}
constructor() {
void sendExtensionMessage("getUserPremiumStatus").then((premiumStatus) => {
this.premiumEnabled = !!premiumStatus?.result;
@ -282,7 +223,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return false;
}
return this.keywordsFoundInFieldData(field, this.creditCardFieldKeywords);
return fieldContainsKeyword(field, this.creditCardFieldKeywords);
}
// If the field has a parent form, check the fields from that form exclusively
@ -302,7 +243,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return false;
}
return this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords]);
return fieldContainsKeyword(field, [...this.creditCardFieldKeywords]);
}
/** Validates the provided field as a field for an account creation form.
@ -340,7 +281,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
// If no password fields are found on the page, check for keywords that indicate the field is
// part of an account creation form.
return this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords);
return fieldContainsKeyword(field, AutoFillConstants.AccountCreationFieldKeywords);
}
// If the field has a parent form, check the fields from that form exclusively
@ -350,7 +291,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords);
return fieldContainsKeyword(field, AutoFillConstants.AccountCreationFieldKeywords);
}
/**
@ -483,7 +424,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
// If any keywords in the field's data indicates that this is a field for a "new" or "changed"
// username, we should assume that this field is not for a login form.
if (this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)) {
if (fieldContainsKeyword(field, AutoFillConstants.AccountCreationFieldKeywords)) {
return false;
}
@ -498,7 +439,11 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
}
const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField);
if (this.isNewsletterForm(parentForm)) {
/**
* If a field is part of a newsletter form, or other recognized non-login type forms, it isn't a username
* {@link AutoFillConstants.NonLoginFormKeywords}
*/
if (isNonLoginFormContext(field, pageDetails)) {
return false;
}
@ -584,11 +529,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.CardHolderFieldNames,
false,
);
return fieldContainsKeyword(field, CreditCardAutoFillConstants.CardHolderFieldNames, false);
};
/**
@ -601,11 +542,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.CardNumberFieldNames,
false,
);
return fieldContainsKeyword(field, CreditCardAutoFillConstants.CardNumberFieldNames, false);
};
/**
@ -620,11 +557,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.CardExpiryFieldNames,
false,
);
return fieldContainsKeyword(field, CreditCardAutoFillConstants.CardExpiryFieldNames, false);
};
/**
@ -639,11 +572,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.ExpiryMonthFieldNames,
false,
);
return fieldContainsKeyword(field, CreditCardAutoFillConstants.ExpiryMonthFieldNames, false);
};
/**
@ -658,11 +587,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.ExpiryYearFieldNames,
false,
);
return fieldContainsKeyword(field, CreditCardAutoFillConstants.ExpiryYearFieldNames, false);
};
/**
@ -675,7 +600,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false);
return fieldContainsKeyword(field, CreditCardAutoFillConstants.CVVFieldNames, false);
};
/**
@ -690,7 +615,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.TitleFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.TitleFieldNames, false);
};
/**
@ -703,11 +628,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.FirstnameFieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.FirstnameFieldNames, false);
};
/**
@ -720,11 +641,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.MiddlenameFieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.MiddlenameFieldNames, false);
};
/**
@ -737,11 +654,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.LastnameFieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.LastnameFieldNames, false);
};
/**
@ -754,11 +667,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.FullNameFieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.FullNameFieldNames, false);
};
/**
@ -771,7 +680,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
return fieldContainsKeyword(
field,
[
...IdentityAutoFillConstants.AddressFieldNames,
@ -791,11 +700,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.Address2FieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.Address2FieldNames, false);
};
/**
@ -808,11 +713,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.Address3FieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.Address3FieldNames, false);
};
/**
@ -825,7 +726,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CityFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.CityFieldNames, false);
};
/**
@ -838,7 +739,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.StateFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.StateFieldNames, false);
};
/**
@ -851,11 +752,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.PostalCodeFieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.PostalCodeFieldNames, false);
};
/**
@ -868,7 +765,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CountryFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.CountryFieldNames, false);
};
/**
@ -881,7 +778,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CompanyFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.CompanyFieldNames, false);
};
/**
@ -894,7 +791,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PhoneFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.PhoneFieldNames, false);
};
/**
@ -915,7 +812,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.EmailFieldNames, false);
return fieldContainsKeyword(field, IdentityAutoFillConstants.EmailFieldNames, false);
};
/**
@ -928,11 +825,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return true;
}
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.UserNameFieldNames,
false,
);
return fieldContainsKeyword(field, IdentityAutoFillConstants.UserNameFieldNames, false);
};
/**
@ -952,7 +845,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return false;
}
return this.keywordsFoundInFieldData(field, AutoFillConstants.UsernameFieldNames);
return fieldContainsKeyword(field, AutoFillConstants.UsernameFieldNames);
};
/**
@ -967,7 +860,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return (
!this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) &&
this.keywordsFoundInFieldData(field, AutoFillConstants.EmailFieldNames)
fieldContainsKeyword(field, AutoFillConstants.EmailFieldNames)
);
};
@ -979,7 +872,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
isCurrentPasswordField = (field: AutofillField): boolean => {
if (
this.fieldContainsAutocompleteValues(field, this.newPasswordAutoCompleteValue) ||
this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)
fieldContainsKeyword(field, AutoFillConstants.AccountCreationFieldKeywords)
) {
return false;
}
@ -999,7 +892,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return (
this.isPasswordField(field) &&
this.keywordsFoundInFieldData(field, this.updatePasswordFieldKeywords)
fieldContainsKeyword(field, AutoFillConstants.UpdatePasswordFieldKeywords)
);
};
@ -1015,7 +908,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return (
this.isPasswordField(field) &&
this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)
fieldContainsKeyword(field, AutoFillConstants.AccountCreationFieldKeywords)
);
};
@ -1090,7 +983,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
* @param field - The field to validate
*/
isTotpField = (field: AutofillField): boolean => {
if (AutofillService.fieldIsFuzzyMatch(field, [...AutoFillConstants.RecoveryCodeFieldNames])) {
if (fieldContainsKeyword(field, [...AutoFillConstants.RecoveryCodeFieldNames])) {
return false;
}
@ -1100,7 +993,7 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return (
!this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) &&
this.keywordsFoundInFieldData(field, AutoFillConstants.TotpFieldNames)
fieldContainsKeyword(field, AutoFillConstants.TotpFieldNames)
);
};
@ -1206,92 +1099,6 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return this.submitButtonKeywordsMap.get(element) || "";
}
/**
* Validates the provided field to indicate if the field has any of the provided keywords.
*
* @param autofillFieldData - The field data to search for keywords
* @param keywords - The keywords to search for
* @param fuzzyMatchKeywords - Indicates if the keywords should be matched in a fuzzy manner
*/
private keywordsFoundInFieldData(
autofillFieldData: AutofillField,
keywords: string[],
fuzzyMatchKeywords = true,
) {
const searchedValues = this.getAutofillFieldDataKeywords(autofillFieldData, fuzzyMatchKeywords);
const parsedKeywords = keywords.map((keyword) => keyword.replace(/-/g, ""));
if (typeof searchedValues === "string") {
return parsedKeywords.some((keyword) => searchedValues.indexOf(keyword) > -1);
}
return parsedKeywords.some((keyword) => searchedValues.has(keyword));
}
/**
* Retrieves the keywords from the provided autofill field data.
*
* @param autofillFieldData - The field data to search for keywords
* @param returnStringValue - Indicates if the method should return a string value
*/
private getAutofillFieldDataKeywords(
autofillFieldData: AutofillField,
returnStringValue: boolean,
) {
if (!this.autofillFieldKeywordsMap.has(autofillFieldData)) {
const keywords = [
autofillFieldData.htmlID,
autofillFieldData.htmlName,
autofillFieldData.htmlClass,
autofillFieldData.type,
autofillFieldData.title,
autofillFieldData.placeholder,
autofillFieldData.autoCompleteType,
autofillFieldData.dataSetValues,
autofillFieldData["label-data"],
autofillFieldData["label-aria"],
autofillFieldData["label-left"],
autofillFieldData["label-right"],
autofillFieldData["label-tag"],
autofillFieldData["label-top"],
];
const keywordsSet = new Set<string>();
for (let i = 0; i < keywords.length; i++) {
const attributeValue = keywords[i];
if (attributeValue && typeof attributeValue === "string") {
let keywordEl = attributeValue.toLowerCase();
keywordsSet.add(keywordEl);
// Remove hyphens from all potential keywords, we want to treat these as a single word.
keywordEl = keywordEl.replace(/-/g, "");
// Split the keyword by non-alphanumeric characters to get the keywords without treating a space as a separator.
keywordEl.split(/[^\p{L}\d]+/gu).forEach((keyword: string) => {
if (keyword) {
keywordsSet.add(keyword);
}
});
// Collapse all spaces and split by non-alphanumeric characters to get the keywords
keywordEl
.replace(/\s/g, "")
.split(/[^\p{L}\d]+/gu)
.forEach((keyword: string) => {
if (keyword) {
keywordsSet.add(keyword);
}
});
}
}
const stringValue = Array.from(keywordsSet).join(",");
this.autofillFieldKeywordsMap.set(autofillFieldData, { keywordsSet, stringValue });
}
const mapValues = this.autofillFieldKeywordsMap.get(autofillFieldData);
return mapValues ? (returnStringValue ? mapValues.stringValue : mapValues.keywordsSet) : "";
}
/**
* Separates the provided field data into space-separated values and checks if any
* of the values are present in the provided set of autocomplete values.

View File

@ -5,12 +5,12 @@ import { logoIcon, logoLockedIcon } from "./svg-icons";
import {
buildSvgDomElement,
debounce,
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction,
debounce,
setupExtensionDisconnectAction,
} from "./index";
describe("buildSvgDomElement", () => {

View File

@ -419,47 +419,6 @@ export function debounce<FunctionType extends (...args: unknown[]) => unknown>(
};
}
/**
* Gathers and normalizes keywords from a potential submit button element. Used
* to verify if the element submits a login or change password form.
*
* @param element - The element to gather keywords from.
*/
export function getSubmitButtonKeywordsSet(element: HTMLElement): Set<string> {
const keywords = [
element.textContent,
element.getAttribute("type"),
element.getAttribute("value"),
element.getAttribute("aria-label"),
element.getAttribute("aria-labelledby"),
element.getAttribute("aria-describedby"),
element.getAttribute("title"),
element.getAttribute("id"),
element.getAttribute("name"),
element.getAttribute("class"),
];
const keywordsSet = new Set<string>();
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (typeof keyword === "string") {
// Iterate over all keywords metadata and split them by non-letter characters.
// This ensures we check against individual words and not the entire string.
keyword
.toLowerCase()
.replace(/[-\s]/g, "")
.split(/[^\p{L}]+/gu)
.forEach((splitKeyword) => {
if (splitKeyword) {
keywordsSet.add(splitKeyword);
}
});
}
}
return keywordsSet;
}
/**
* Generates the origin and subdomain match patterns for the URL.
*

View File

@ -0,0 +1,92 @@
import { createAutofillFieldMock } from "../spec/autofill-mocks";
import { fieldContainsKeyword } from "./qualification";
describe("fieldContainsKeyword", () => {
it("returns false if the field has no matching attribute values", () => {
const field = createAutofillFieldMock({ htmlID: "unrelated", htmlName: "unrelated" });
expect(fieldContainsKeyword(field, ["password"])).toBe(false);
});
it("substring mode (default): matches a keyword appearing within a token", () => {
const field = createAutofillFieldMock({ htmlID: "my-password-field" });
expect(fieldContainsKeyword(field, ["password"])).toBe(true);
});
it("substring mode: matches via tokenization of a hyphenated field ID", () => {
const field = createAutofillFieldMock({ htmlID: "credit-card-number" });
expect(fieldContainsKeyword(field, ["creditcardnumber"])).toBe(true);
});
it("exact mode: matches a keyword that is exactly a token", () => {
const field = createAutofillFieldMock({ htmlName: "email" });
expect(fieldContainsKeyword(field, ["email"], false)).toBe(true);
});
it("exact mode: does not match a keyword that is only a substring of a token", () => {
const field = createAutofillFieldMock({ htmlName: "emailaddress" });
expect(fieldContainsKeyword(field, ["email"], false)).toBe(false);
});
it("caching: second call on same field uses cached data without re-computing", () => {
const field = createAutofillFieldMock({ htmlID: "username" });
const result1 = fieldContainsKeyword(field, ["username"]);
const result2 = fieldContainsKeyword(field, ["username"]);
expect(result1).toBe(true);
expect(result2).toBe(true);
});
it("hyphenated keyword: strips hyphens before matching so 'new-password' matches htmlName 'newpassword'", () => {
const field = createAutofillFieldMock({ htmlName: "newpassword" });
expect(fieldContainsKeyword(field, ["new-password"])).toBe(true);
});
it("multi-word keyword: does not match when the words appear non-contiguously in the field value", () => {
// "create password" should not match "Create your password" — the intervening word breaks
// the contiguous substring. This documents that multi-word keywords require an exact phrase match.
const field = createAutofillFieldMock({ placeholder: "Create your password" });
expect(fieldContainsKeyword(field, ["create password"])).toBe(false);
});
it("label attributes: matches a keyword found in label-tag when not present in htmlID or htmlName", () => {
const field = createAutofillFieldMock({
htmlID: "oid",
htmlName: "oid",
"label-tag": "User ID",
});
// "User ID" tokenizes to include "userid", which is in UsernameFieldNames.
// This is the label-awareness introduced by this PR.
expect(fieldContainsKeyword(field, ["userid"])).toBe(true);
});
it("null/falsy attributes: returns false without throwing when all checked attributes are empty", () => {
const field = createAutofillFieldMock({
htmlID: "",
htmlName: "",
htmlClass: "",
type: "",
title: "",
placeholder: "",
autoCompleteType: "",
dataSetValues: "",
"label-data": "",
"label-aria": "",
"label-left": "",
"label-right": "",
"label-tag": "",
"label-top": "",
});
expect(fieldContainsKeyword(field, ["username"])).toBe(false);
});
});

View File

@ -0,0 +1,173 @@
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import { AutoFillConstants } from "../services/autofill-constants";
// Module-level cache
const autofillFieldKeywordsCache: WeakMap<
AutofillField,
{ keywordsSet: Set<string>; stringValue: string }
> = new WeakMap();
/**
* Normalizes and tokenizes a single attribute value string into a set of keyword tokens.
* Produces the full lowercased value, tokens split on non-alphanumeric characters (after
* hyphen removal), and tokens split after additional space removal (e.g. "user id" "userid").
*/
function tokenizeValue(value: string): Set<string> {
const keywordsSet = new Set<string>();
let keywordEl = value.toLowerCase();
keywordsSet.add(keywordEl);
keywordEl = keywordEl.replace(/-/g, "");
keywordEl.split(/[^\p{L}\d]+/gu).forEach((k) => {
if (k) {
keywordsSet.add(k);
}
});
keywordEl
.replace(/\s/g, "")
.split(/[^\p{L}\d]+/gu)
.forEach((k) => {
if (k) {
keywordsSet.add(k);
}
});
return keywordsSet;
}
/**
* Collects and tokenizes all qualifying attribute values from a field into a unified
* keyword set and a comma-joined string value. Results are cached per field reference
* in {@link autofillFieldKeywordsCache} to avoid redundant computation across repeated calls.
*/
function buildAutofillFieldKeywords(field: AutofillField) {
if (autofillFieldKeywordsCache.has(field)) {
return autofillFieldKeywordsCache.get(field)!;
}
const attributeValues = [
field.htmlID,
field.htmlName,
field.htmlClass,
field.type,
field.title,
field.placeholder,
field.autoCompleteType,
field.dataSetValues,
field["label-data"],
field["label-aria"],
field["label-left"],
field["label-right"],
field["label-tag"],
field["label-top"],
];
const keywordsSet = new Set<string>();
for (const attributeValue of attributeValues) {
if (!attributeValue || typeof attributeValue !== "string") {
continue;
}
tokenizeValue(attributeValue).forEach((k) => keywordsSet.add(k));
}
const result = { keywordsSet, stringValue: Array.from(keywordsSet).join(",") };
autofillFieldKeywordsCache.set(field, result);
return result;
}
/**
* Returns true if any of the provided keywords is found in the field's attributes.
* Strips hyphens from input keywords before matching.
*
* @param field - The AutofillField to check
* @param keywords - Keywords to search for
* @param substringMatch - If true (default), keyword is searched as a substring across the
* field's normalized attribute data. If false, keyword must be an exact token in the set.
*/
export function fieldContainsKeyword(
field: AutofillField,
keywords: string[],
substringMatch = true,
): boolean {
const parsedKeywords = keywords.map((k) => k.replace(/-/g, ""));
const { keywordsSet, stringValue } = buildAutofillFieldKeywords(field);
if (substringMatch) {
return parsedKeywords.some((k) => stringValue.indexOf(k) > -1);
}
return parsedKeywords.some((k) => keywordsSet.has(k));
}
/**
* Gathers and normalizes keywords from a potential submit button element. Used
* to verify if the element submits a login or change password form.
*
* @param element - The element to gather keywords from.
*/
export function getSubmitButtonKeywordsSet(element: HTMLElement): Set<string> {
const keywords = [
element.textContent,
element.getAttribute("type"),
element.getAttribute("value"),
element.getAttribute("aria-label"),
element.getAttribute("aria-labelledby"),
element.getAttribute("aria-describedby"),
element.getAttribute("title"),
element.getAttribute("id"),
element.getAttribute("name"),
element.getAttribute("class"),
];
const keywordsSet = new Set<string>();
for (const keyword of keywords) {
if (typeof keyword === "string") {
// Iterate over all keywords metadata and split them by non-letter characters.
// This ensures we check against individual words and not the entire string.
keyword
.toLowerCase()
.replace(/[-\s]/g, "")
.split(/[^\p{L}]+/gu)
.forEach((splitKeyword) => {
if (splitKeyword) {
keywordsSet.add(splitKeyword);
}
});
}
}
return keywordsSet;
}
/**
* Returns true if the field's parent form contains keywords indicating a non-login
* context (e.g. newsletter signup, subscription forms). Checks the form's {@link AutofillForm.htmlID},
* {@link AutofillForm.htmlName}, and {@link AutofillForm.htmlAction} attributes against
* {@link AutoFillConstants.NonLoginFormKeywords}. Returns false when the field has no parent form.
*
* @param field - The AutofillField whose parent form is to be checked
* @param pageDetails - Page details containing the forms map
*/
export function isNonLoginFormContext(
field: AutofillField,
pageDetails: AutofillPageDetails,
): boolean {
const fieldForm = field.form;
if (!fieldForm) {
return false;
}
const parentForm = pageDetails.forms?.[fieldForm];
if (!parentForm) {
return false;
}
const formAttributes = [parentForm.htmlID, parentForm.htmlName, parentForm.htmlAction];
for (const attr of formAttributes) {
if (!attr || typeof attr !== "string") {
continue;
}
const attrLower = attr.toLowerCase();
for (const keyword of AutoFillConstants.NonLoginFormKeywords) {
if (attrLower.includes(keyword)) {
return true;
}
}
}
return false;
}