mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-07 21:10:55 +08:00
Share inline menu qualification service implementation of keyword matching with autofill service (#19263)
This commit is contained in:
parent
5010c47864
commit
6380248ce9
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -5,12 +5,12 @@ import { logoIcon, logoLockedIcon } from "./svg-icons";
|
||||
|
||||
import {
|
||||
buildSvgDomElement,
|
||||
debounce,
|
||||
generateRandomCustomElementName,
|
||||
sendExtensionMessage,
|
||||
setElementStyles,
|
||||
setupExtensionDisconnectAction,
|
||||
setupAutofillInitDisconnectAction,
|
||||
debounce,
|
||||
setupExtensionDisconnectAction,
|
||||
} from "./index";
|
||||
|
||||
describe("buildSvgDomElement", () => {
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
92
apps/browser/src/autofill/utils/qualification.spec.ts
Normal file
92
apps/browser/src/autofill/utils/qualification.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
apps/browser/src/autofill/utils/qualification.ts
Normal file
173
apps/browser/src/autofill/utils/qualification.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user