diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index 172c02244956..91d097ce1d38 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -1,6 +1,20 @@ import { AutofillTargetingRuleTypes } from "@bitwarden/common/autofill/constants"; import { AutofillTargetingRuleType } from "@bitwarden/common/autofill/types"; +const targetingRuleTypeValues: ReadonlySet = new Set( + Object.values(AutofillTargetingRuleTypes), +); + +/** + * Type guard for `AutofillTargetingRuleType`. Use at boundaries where a value + * typed more loosely (e.g. `AutofillField.fieldQualifier`, which can carry + * either heuristic or targeting-rule qualifiers) needs to be narrowed before + * being compared against targeting-rule-specific groupings. + */ +export function isAutofillTargetingRuleType(value: unknown): value is AutofillTargetingRuleType { + return typeof value === "string" && targetingRuleTypeValues.has(value); +} + export const loginQualifiers: AutofillTargetingRuleType[] = [ AutofillTargetingRuleTypes.username, AutofillTargetingRuleTypes.password, diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 5551057f62e7..175e1bb27e8d 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -9,7 +9,6 @@ import { AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT, EVENTS, } from "@bitwarden/common/autofill/constants"; -import { AutofillTargetingRuleType } from "@bitwarden/common/autofill/types"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ModifyLoginCipherFormData } from "../background/abstractions/overlay-notifications.background"; @@ -62,6 +61,7 @@ import { loginQualifiers, cardQualifiers, identityQualifiers, + isAutofillTargetingRuleType, } from "./autofill-constants"; export class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface { @@ -1174,33 +1174,27 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * bypassing heuristic qualification. */ private setTargetedFieldFillType(autofillFieldData: AutofillField): void { - // Targeted fields use AutofillTargetingRuleType values in fieldQualifier, - // which are distinct from the heuristic AutofillFieldQualifierType values. - const qualifier = autofillFieldData.fieldQualifier as AutofillTargetingRuleType; + // Targeted fields use `AutofillTargetingRuleType` values in `fieldQualifier`, + // which are distinct from the heuristic `AutofillFieldQualifierType` values. + const qualifier = autofillFieldData.fieldQualifier; - if (qualifier === AutofillTargetingRuleTypes.newPassword) { + if (!isAutofillTargetingRuleType(qualifier)) { + autofillFieldData.inlineMenuFillType = CipherType.Login; + } else if (qualifier === AutofillTargetingRuleTypes.newPassword) { autofillFieldData.inlineMenuFillType = InlineMenuFillTypes.PasswordGeneration; - return; - } - - if (loginQualifiers.includes(qualifier)) { + } else if (loginQualifiers.includes(qualifier)) { autofillFieldData.inlineMenuFillType = CipherType.Login; - return; - } - - if (cardQualifiers.includes(qualifier)) { + } else if (cardQualifiers.includes(qualifier)) { autofillFieldData.inlineMenuFillType = CipherType.Card; - return; - } - - if (identityQualifiers.includes(qualifier)) { + } else if (identityQualifiers.includes(qualifier)) { autofillFieldData.inlineMenuFillType = CipherType.Identity; - return; + } else { + // The fallback `CipherType.Login` covers both the unrecognized-qualifier + // case and qualifier groups that don't have a dedicated fill type yet; + // it avoids inline menu re-render churn that occurs when + // `inlineMenuFillType` is left undefined. + autofillFieldData.inlineMenuFillType = CipherType.Login; } - - // Fallback: default to Login to avoid inline menu re-render churn that - // occurs when `inlineMenuFillType` is left undefined. - autofillFieldData.inlineMenuFillType = CipherType.Login; } /** diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 838c598f93e4..a85877c5dc60 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -1,5 +1,7 @@ import { mock } from "jest-mock-extended"; +import { FormContent } from "@bitwarden/common/autofill/types"; + import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import { createAutofillFieldMock, createAutofillFormMock } from "../spec/autofill-mocks"; @@ -431,7 +433,7 @@ describe("CollectAutofillContentService", () => { const element = document.getElementById("username") as ElementWithOpId; expect(collectAutofillContentService["autofillFieldElements"].has(element)).toBe(true); expect( - collectAutofillContentService["autofillFieldsByOpid"].has("targeted_field_0_username"), + collectAutofillContentService["autofillFieldsByOpid"].has("targeted_field_0_username_0"), ).toBe(true); }); @@ -466,62 +468,6 @@ describe("CollectAutofillContentService", () => { }); }); - describe("getTargetedPageDetails cached-field fallback", () => { - beforeEach(() => { - jest - .spyOn(collectAutofillContentService as any, "sendExtensionMessage") - .mockImplementation((command: string) => { - if (command === "getUrlAutofillTargetingRules") { - return Promise.resolve({ - result: [ - { - fields: { - username: ["iframe#nonexistent >>> #username"], - }, - }, - ], - }); - } - return Promise.resolve(undefined); - }); - jest - .spyOn(collectAutofillContentService as any, "setupMutationObserver") - .mockImplementationOnce(() => { - collectAutofillContentService["mutationObserver"] = mock(); - }); - }); - - it("returns empty page details when no local fields match and autofillFieldElements is empty", async () => { - document.body.innerHTML = ``; - - const pageDetails = await collectAutofillContentService.getPageDetails(); - - expect(pageDetails.fields).toHaveLength(0); - }); - - it("returns cached page details from applyExternalTargetedFields when no local fields match", async () => { - document.body.innerHTML = ``; - - const targetedFields = [{ selector: "#username", fieldType: "username" }]; - jest - .spyOn(collectAutofillContentService as any, "sendExtensionMessage") - .mockImplementation((command: string) => { - if (command === "getUrlAutofillTargetingRules") { - return Promise.resolve({ - result: [{ fields: { username: ["iframe#nonexistent >>> #username"] } }], - }); - } - return Promise.resolve(undefined); - }); - await collectAutofillContentService.applyExternalTargetedFields(targetedFields); - - const pageDetails = await collectAutofillContentService.getPageDetails(); - - expect(pageDetails.fields).toHaveLength(1); - expect(pageDetails.fields[0].opid).toBe("targeted_field_0_username"); - }); - }); - describe("getTargetedPageDetails iframe routing", () => { beforeEach(() => { jest @@ -662,6 +608,293 @@ describe("CollectAutofillContentService", () => { }); }); + describe("getTargetedPageDetails (container resolution)", () => { + const mockTargetingRules = (rules: FormContent[] | null) => { + jest + .spyOn(collectAutofillContentService as any, "sendExtensionMessage") + .mockImplementation((command: unknown) => { + if (command === "getUrlAutofillTargetingRules") { + return Promise.resolve({ result: rules }); + } + return Promise.resolve({ result: null }); + }); + jest + .spyOn(collectAutofillContentService as any, "setupMutationObserver") + .mockImplementationOnce(() => { + collectAutofillContentService["mutationObserver"] = mock(); + }); + }; + + it("builds an AutofillForm from a
container and associates fields with it", async () => { + document.body.innerHTML = ` + + + +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: ["form#login-form"], + fields: { + username: ["input#user"], + password: ["input#pw"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(Object.keys(pageDetails.forms)).toEqual(["targeted_form_0"]); + const form = pageDetails.forms["targeted_form_0"]; + expect(form.htmlID).toBe("login-form"); + expect(form.htmlName).toBe("login"); + expect(form.htmlClass).toBe("auth"); + expect(form.htmlMethod).toBe("post"); + expect(form.htmlAction).toBe(new URL("/sign-in", globalThis.location.href).href); + expect(form.htmlAncestorHeadings).toEqual([]); + expect(pageDetails.fields).toHaveLength(2); + expect(pageDetails.fields.every((f) => f.form === "targeted_form_0")).toBe(true); + }); + + it("builds an AutofillForm from a role='form' container with empty action/method", async () => { + document.body.innerHTML = ` +
+ + +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: ["div[role='form']#login-card"], + fields: { + username: ["input#user"], + password: ["input#pw"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + const form = pageDetails.forms["targeted_form_0"]; + expect(form.htmlID).toBe("login-card"); + expect(form.htmlClass).toBe("auth-card"); + expect(form.htmlAction).toBe(""); + expect(form.htmlMethod).toBe(""); + expect(pageDetails.fields.every((f) => f.form === "targeted_form_0")).toBe(true); + }); + + it("uses the first matching alternative when multiple container selectors are provided", async () => { + document.body.innerHTML = ` +
+ +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: ["form#does-not-exist", "form#actual-form"], + fields: { + username: ["input#user"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(pageDetails.forms["targeted_form_0"].htmlID).toBe("actual-form"); + expect(pageDetails.fields[0].form).toBe("targeted_form_0"); + }); + + it("creates no form record and leaves fields unassociated when the container does not resolve", async () => { + document.body.innerHTML = ` +
+ +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: ["form#missing-container"], + fields: { + username: ["input#user"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(pageDetails.forms).toEqual({}); + expect(pageDetails.fields).toHaveLength(1); + expect(pageDetails.fields[0].form).toBeNull(); + }); + + it("creates no form record when the container property is omitted", async () => { + document.body.innerHTML = ` +
+ +
+ `; + mockTargetingRules([ + { + category: "account-login", + fields: { + username: ["input#user"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(pageDetails.forms).toEqual({}); + expect(pageDetails.fields[0].form).toBeNull(); + }); + + it("skips array (sequence) entries in the container array without throwing", async () => { + document.body.innerHTML = ` +
+ +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: [["input#never-applies"] as any, "form#login-form"], + fields: { + username: ["input#user"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(pageDetails.forms["targeted_form_0"].htmlID).toBe("login-form"); + }); + + it("routes fields to their own form when multiple FormContent entries have distinct containers", async () => { + document.body.innerHTML = ` +
+ + +
+
+ + +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: ["form#login-form"], + fields: { + username: ["input#login-user"], + password: ["input#login-pw"], + }, + }, + { + category: "account-creation", + container: ["form#signup-form"], + fields: { + username: ["input#signup-user"], + newPassword: ["input#signup-pw"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(Object.keys(pageDetails.forms).sort()).toEqual(["targeted_form_0", "targeted_form_1"]); + const loginFields = pageDetails.fields.filter((f) => f.form === "targeted_form_0"); + const signupFields = pageDetails.fields.filter((f) => f.form === "targeted_form_1"); + expect(loginFields.map((f) => f.opid).sort()).toEqual([ + "targeted_field_0_username_0", + "targeted_field_1_password_0", + ]); + expect(signupFields.map((f) => f.opid).sort()).toEqual([ + "targeted_field_2_username_0", + "targeted_field_3_newPassword_0", + ]); + }); + + it("collects up to two newPassword matches from a single selector array (re-entry pattern)", async () => { + document.body.innerHTML = ` +
+ + +
+ `; + mockTargetingRules([ + { + category: "account-update", + container: ["form#change-password"], + fields: { + newPassword: ["input[name='newPassword']", "input[name='confirmPassword']"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + const newPasswordFields = pageDetails.fields.filter( + (f) => f.fieldQualifier === "newPassword", + ); + expect(newPasswordFields).toHaveLength(2); + expect(newPasswordFields.map((f) => f.opid).sort()).toEqual([ + "targeted_field_0_newPassword_0", + "targeted_field_0_newPassword_1", + ]); + }); + + it("stops at the first match for non-newPassword field types", async () => { + document.body.innerHTML = ` +
+ + +
+ `; + mockTargetingRules([ + { + category: "account-login", + container: ["form#login-form"], + fields: { + username: ["input[name='username']", "input[name='email']"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + const usernameFields = pageDetails.fields.filter((f) => f.fieldQualifier === "username"); + expect(usernameFields).toHaveLength(1); + expect(usernameFields[0].opid).toBe("targeted_field_0_username_0"); + }); + + it("dedupes by element identity when two newPassword selectors resolve to the same node", async () => { + document.body.innerHTML = ` +
+ +
+ `; + mockTargetingRules([ + { + category: "account-update", + container: ["form#change-password"], + fields: { + newPassword: ["input[name='newPassword']", "input[data-test='new']"], + }, + }, + ]); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + const newPasswordFields = pageDetails.fields.filter( + (f) => f.fieldQualifier === "newPassword", + ); + expect(newPasswordFields).toHaveLength(1); + }); + }); + describe("getAutofillFieldElementByOpid", () => { it("returns the element with the opid property value matching the passed value", () => { const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index ef55dc3bf1dc..05827f34d604 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,4 +1,7 @@ -import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants"; +import { + AUTOFILL_ATTRIBUTES, + AutofillTargetingRuleTypes, +} from "@bitwarden/common/autofill/constants"; import { AutofillTargetingRuleType, FormContent } from "@bitwarden/common/autofill/types"; import AutofillField from "../models/autofill-field"; @@ -37,9 +40,23 @@ import { DomElementVisibilityService } from "./abstractions/dom-element-visibili import { DomQueryService } from "./abstractions/dom-query.service"; import { AutoFillConstants } from "./autofill-constants"; +/** + * Per-field-type cap on how many DOM matches the targeted-collection pass + * accepts from a selector array. Selector arrays are alternatives by default + * (first match wins), but certain field types appear in pairs on the same + * form; most notably `newPassword`, which is commonly mirrored by a + * "confirm new password" input on registration and password-update flows. + */ +const MAX_MATCHES_BY_FIELD_TYPE: Readonly>> = + Object.freeze({ + [AutofillTargetingRuleTypes.newPassword]: 2, + }); +const DEFAULT_MAX_MATCHES = 1; + type ResolveFieldTarget = { selectorAlternatives: string[]; fieldType: AutofillTargetingRuleType; + formOpid?: string | null; }; export class CollectAutofillContentService implements CollectAutofillContentServiceInterface { @@ -265,48 +282,98 @@ export class CollectAutofillContentService implements CollectAutofillContentServ /** * Builds page details using targeting rule selectors instead of heuristic - * detection. Iterates through form definitions, resolving each field type's - * selector array by trying each `DeepSelector` in order and stopping at the - * first DOM match. + * detection. Iterates through form definitions, resolving each form's + * optional `container` selector and each field type's selector array by + * trying each `DeepSelector` in order and stopping at the first DOM match. + * + * When a `container` resolves locally, fields belonging to that + * `FormContent` are associated with the resolved form record via + * `field.form`. When it does not resolve (or is absent or crosses an + * `iframe` boundary), fields are left unassociated; the targeted path is + * mutually-exclusive with heuristics and does not fall back to native + * `.form` ancestry. */ private getTargetedPageDetails(forms: FormContent[]): AutofillPageDetails { - const targets: ResolveFieldTarget[] = forms.flatMap((form) => - (Object.entries(form.fields) as Array<[AutofillTargetingRuleType, string[]]>) + const autofillFormsData: Record = {}; + const targets: ResolveFieldTarget[] = forms.flatMap((form, formIndex) => { + const containerElement = this.resolveTargetedContainerElement(form.container); + let formOpid: string | null = null; + if (containerElement) { + formOpid = `targeted_form_${formIndex}`; + autofillFormsData[formOpid] = this.buildTargetedAutofillForm(containerElement, formOpid); + } + return (Object.entries(form.fields) as Array<[AutofillTargetingRuleType, string[]]>) .filter(([, alternatives]) => alternatives?.length) - .map(([fieldType, selectorAlternatives]) => ({ fieldType, selectorAlternatives })), - ); + .map(([fieldType, selectorAlternatives]) => ({ + fieldType, + selectorAlternatives, + formOpid, + })); + }); const { localFields, iframeTargets } = this.resolveTargetedFields(targets); this.routeIframeTargets(iframeTargets); - // If this frame resolved no local targeted fields but already has fields cached - // from a prior applyExternalTargetedFields call, use those cached fields. This - // handles the case where an iframe's own getPageDetails() runs the targeting path - // (because targeting rules apply to the whole tab URL) but all selectors in the - // rules cross an iframe boundary that doesn't exist inside this frame — so the - // results are empty, and we must not overwrite the background's page-details entry - // with an empty payload. - if (!localFields.length && this.autofillFieldElements.size > 0) { - this.domRecentlyMutated = false; - const cachedPageDetails = this.getFormattedPageDetails( - this.getFormattedAutofillFormsData(), - this.getFormattedAutofillFieldsData(), - ); - this.setupOverlayListeners(cachedPageDetails); - return cachedPageDetails; - } - this.domRecentlyMutated = false; /** - * @TODO check if need to utilize targeting rules for forms/submits within closed + * FIXME check if need to utilize targeting rules for forms/submits within closed * shadow roots as well, in order to detect cipher additions/updates */ - const pageDetails = this.getFormattedPageDetails({}, localFields); + const pageDetails = this.getFormattedPageDetails(autofillFormsData, localFields); this.setupOverlayListeners(pageDetails); return pageDetails; } + /** + * Resolves a targeting rule's `container` selector array to the first DOM + * element matched by any string entry. Skips `DeepSelectorSequence` + * (string[]) entries (sequences are nonsensical for a single container) and + * skips entries that cross an iframe boundary (container routing across + * frames is out of scope; the receiving frame has no container metadata). + * Returns `null` when no entry resolves, when the array is empty, or when + * `container` is undefined. + */ + private resolveTargetedContainerElement(container: FormContent["container"]): HTMLElement | null { + if (!container?.length) { + return null; + } + for (const selector of container) { + if (typeof selector !== "string") { + continue; + } + if (this.domQueryService.findIframeCrossing(selector)) { + continue; + } + const match = this.domQueryService.queryDeepSelector(selector); + if (match instanceof HTMLElement) { + return match; + } + } + return null; + } + + /** + * Builds an AutofillForm record for a container element resolved from a + * targeting rule. `id`, `name`, and `class` are read from any element type; + * `action` and `method` are only populated for actual `
` elements + * (a `
` container, for example, has neither). + */ + private buildTargetedAutofillForm(element: HTMLElement, opid: string): AutofillForm { + const isFormElement = element instanceof HTMLFormElement; + const form = new AutofillForm(); + form.opid = opid; + form.htmlID = this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ID) ?? ""; + form.htmlName = this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.NAME) ?? ""; + form.htmlClass = this.getPropertyOrAttribute(element, "class") ?? ""; + form.htmlAction = isFormElement ? (this.getFormActionAttribute(element) ?? "") : ""; + form.htmlMethod = isFormElement + ? (this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.METHOD) ?? "") + : ""; + form.htmlAncestorHeadings = []; + return form; + } + /** * Applies externally-provided targeting rules to this frame. Called when the * background dispatches `applyTargetedFields` after a parent frame's @@ -385,12 +452,22 @@ export class CollectAutofillContentService implements CollectAutofillContentServ >(); for (let targetIndex = 0; targetIndex < targets.length; targetIndex++) { - const { selectorAlternatives, fieldType } = targets[targetIndex]; + const { selectorAlternatives, fieldType, formOpid } = targets[targetIndex]; if (!selectorAlternatives?.length) { continue; } + const maxMatches = MAX_MATCHES_BY_FIELD_TYPE[fieldType] ?? DEFAULT_MAX_MATCHES; + // Dedupe by element identity in case two selectors in the array + // resolve to the same node. + const matchedElements = new Set(); + let matchCount = 0; + for (const selector of selectorAlternatives) { + if (matchCount >= maxMatches) { + break; + } + if (typeof selector !== "string") { continue; } @@ -401,35 +478,43 @@ export class CollectAutofillContentService implements CollectAutofillContentServ if (iframeCrossing) { const { iframeElement, innerSelector } = iframeCrossing; const iframeSrc = iframeElement.contentDocument?.location?.href || iframeElement.src; - // Empty src (srcdoc, about:blank) is deferred — see routing/scope notes. - if (iframeSrc) { - if (!iframeTargets.has(iframeSrc)) { - iframeTargets.set(iframeSrc, []); - } - iframeTargets.get(iframeSrc)!.push({ - selector: innerSelector, - fieldType, - }); + // An iframe with no resolvable source (constructed but not yet + // navigated, srcdoc, about:blank) is not counted, so a later + // alternative in the same selector array still gets a chance to + // resolve. + if (!iframeSrc) { + continue; } - break; + if (!iframeTargets.has(iframeSrc)) { + iframeTargets.set(iframeSrc, []); + } + iframeTargets.get(iframeSrc)!.push({ + selector: innerSelector, + fieldType, + }); + matchCount++; + continue; } // No iframe boundary — resolve locally (direct element or shadow DOM). const matchedElement = this.domQueryService.queryDeepSelector(selector); - if (matchedElement) { - const fieldId = `targeted_field_${targetIndex}_${fieldType}`; - const formFieldElement = matchedElement as ElementWithOpId; - formFieldElement.opid = fieldId; - - const autofillField = this.buildTargetedAutofillField( - formFieldElement, - fieldType, - localFields.length, - ); - localFields.push(autofillField); - this.cacheAutofillFieldElement(localFields.length - 1, formFieldElement, autofillField); - break; + if (!matchedElement || matchedElements.has(matchedElement)) { + continue; } + matchedElements.add(matchedElement); + + const formFieldElement = matchedElement as ElementWithOpId; + formFieldElement.opid = `targeted_field_${targetIndex}_${fieldType}_${matchCount}`; + + const autofillField = this.buildTargetedAutofillField( + formFieldElement, + fieldType, + localFields.length, + ); + autofillField.form = formOpid ?? null; + localFields.push(autofillField); + this.cacheAutofillFieldElement(localFields.length - 1, formFieldElement, autofillField); + matchCount++; } } @@ -590,11 +675,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ /** * Returns the action attribute of the form element. If the action attribute * is a relative path, it will be converted to an absolute path. - * @param {ElementWithOpId} element + * @param {HTMLFormElement} element * @returns {string | null} * @private */ - private getFormActionAttribute(element: ElementWithOpId): string | null { + private getFormActionAttribute(element: HTMLFormElement): string | null { const action = this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ACTION); if (action === null) { return null;