diff --git a/apps/browser/src/autofill/content/auto-submit-login.ts b/apps/browser/src/autofill/content/auto-submit-login.ts index 511d35d7a49..0d1d8f8e64e 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.ts @@ -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(); diff --git a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts index 04033664e9a..16bc78ff8b7 100644 --- a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts +++ b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts @@ -1,14 +1,6 @@ import AutofillField from "../../models/autofill-field"; import AutofillPageDetails from "../../models/autofill-page-details"; -export type AutofillKeywordsMap = WeakMap< - AutofillField, - { - keywordsSet: Set; - stringValue: string; - } ->; - export type SubmitButtonKeywordsMap = WeakMap; export interface InlineMenuFieldQualificationService { diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index d2c92fc73ca..52e0c854bac 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -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"]; diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index f53e81e44a0..4d794bad42b 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -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); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index e187602fc67..66f5843cb49 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -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. diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 9ce2236592e..4db4a97119e 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -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(); - 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. diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 62a707860c3..43984f44bc8 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -5,12 +5,12 @@ import { logoIcon, logoLockedIcon } from "./svg-icons"; import { buildSvgDomElement, + debounce, generateRandomCustomElementName, sendExtensionMessage, setElementStyles, - setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, - debounce, + setupExtensionDisconnectAction, } from "./index"; describe("buildSvgDomElement", () => { diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index fa47ddd943b..83859d1a5c6 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -419,47 +419,6 @@ export function debounce 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 { - 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(); - 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. * diff --git a/apps/browser/src/autofill/utils/qualification.spec.ts b/apps/browser/src/autofill/utils/qualification.spec.ts new file mode 100644 index 00000000000..c7dbf1510da --- /dev/null +++ b/apps/browser/src/autofill/utils/qualification.spec.ts @@ -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); + }); +}); diff --git a/apps/browser/src/autofill/utils/qualification.ts b/apps/browser/src/autofill/utils/qualification.ts new file mode 100644 index 00000000000..b92c699fb3c --- /dev/null +++ b/apps/browser/src/autofill/utils/qualification.ts @@ -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; 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 { + const keywordsSet = new Set(); + 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(); + 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 { + 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(); + 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; +}