/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs", AutofillFormFactory: "resource://gre/modules/shared/AutofillFormFactory.sys.mjs", AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs", CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs", GenericAutocompleteItem: "resource://gre/modules/FillHelpers.sys.mjs", InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs", FieldDetail: "resource://gre/modules/shared/FieldScanner.sys.mjs", FormAutofill: "resource://autofill/FormAutofill.sys.mjs", FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs", FormAutofillHandler: "resource://gre/modules/shared/FormAutofillHandler.sys.mjs", FORM_CHANGE_REASON: "resource://gre/modules/shared/FormAutofillHandler.sys.mjs", FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs", FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", FORM_SUBMISSION_REASON: "resource://gre/actors/FormHandlerChild.sys.mjs", clearTimeout: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); /** * Handles content's interactions for the frame. */ export class FormAutofillChild extends JSWindowActorChild { /** * Keep track of autofill handlers that are waiting for the parent process * to send back the identified result. */ #handlerWaitingForDetectedComplete = new Set(); /** * Keep track of handler that are waiting for the * notification to re-fill fields after a form change */ #handlerWaitingForFillOnFormChangeComplete = new Set(); /** * Keep track of handler that are waiting for the parent process * to complete the previous form submission action. This is needed * to prevent the field update heuristics from changing the detected field * details while the form submission heuristics is trying to capture them. */ #handlerWaitingForFormSubmissionComplete = new Set(); constructor() { super(); this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillChild"); this.debug("init"); this._hasDOMContentLoadedHandler = false; this._hasRegisteredPageHide = new Set(); /** * @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers. */ this._fieldDetailsManager = new lazy.FormStateManager( this.onFilledModified.bind(this) ); /** * Tracks whether the last form submission was triggered by a form submit event, * if so we'll ignore the page navigation that follows */ this.isFollowingSubmitEvent = false; } /** * After the parent process finishes classifying the fields, the parent process * informs all the child process of the classified field result. The child process * then sets the updated result to the corresponding AutofillHandler * * @param {Array} fieldDetails * An array of the identified fields. * @param {boolean} isUpdate flags whether the field detection process * is run due to a form change */ onFieldsDetectedComplete(fieldDetails, isUpdate = false) { if (!fieldDetails.length) { return; } const handler = this._fieldDetailsManager.getFormHandlerByRootElementId( fieldDetails[0].rootElementId ); this.#handlerWaitingForDetectedComplete.delete(handler); if (isUpdate) { if (this.#handlerWaitingForFormSubmissionComplete.has(handler)) { // The form change was detected before the form submission, but was probably initiated // by it, so don't touch the fieldDetails in this case. return; } handler.updateFormByElement(fieldDetails[0].element); this._fieldDetailsManager.addFormHandlerByElementEntries(handler); } handler.setIdentifiedFieldDetails(fieldDetails); handler.setUpDynamicFormChangeObserver(); let addressFields = []; let creditcardFields = []; handler.fieldDetails.forEach(fd => { if (lazy.FormAutofillUtils.isAddressField(fd.fieldName)) { addressFields.push(fd); } else if (lazy.FormAutofillUtils.isCreditCardField(fd.fieldName)) { creditcardFields.push(fd); } }); // Bug 1905040. This is only a temporarily workaround for now to skip marking address fields // autocompletable whenever we detect an address field. We only mark address field when // it is a valid address section (This is done in the parent) const addressFieldSet = new Set(addressFields.map(fd => fd.fieldName)); if ( addressFieldSet.size < lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD ) { addressFields = []; } // Inform the autocomplete controller these fields are autofillable [...addressFields, ...creditcardFields].forEach(fieldDetail => { this.#markAsAutofillField(fieldDetail); if ( fieldDetail.element == lazy.FormAutofillContent.focusedElement && !isUpdate ) { this.showPopupIfEmpty(fieldDetail.element, fieldDetail.fieldName); } }); if (isUpdate) { // The fields detection was re-run because of a form change, this means // FormAutofillChild already registered its interest in form submissions // in the initial field detection process return; } // Do not need to listen to form submission event because if the address fields do not contain // 'street-address' or `address-linx`, we will not save the address. if ( creditcardFields.length || (addressFields.length && [ "street-address", "address-line1", "address-line2", "address-line3", ].some(fieldName => addressFieldSet.has(fieldName))) ) { this.manager .getActor("FormHandler") .registerFormSubmissionInterest(this, { includesFormRemoval: lazy.FormAutofill.captureOnFormRemoval, includesPageNavigation: lazy.FormAutofill.captureOnPageNavigation, }); // TODO (Bug 1901486): Integrate pagehide to FormHandler. if (!this._hasRegisteredPageHide.has(handler)) { this.registerPageHide(handler); this._hasRegisteredPageHide.add(true); } } } /** * Disconnect all remaining form change observer that are still set up * for the form that was submitted. * * @param {string} rootElementId */ onFormSubmissionComplete(rootElementId) { const handler = this._fieldDetailsManager.getFormHandlerByRootElementId(rootElementId); handler.clearFormChangeObservers(); this.#handlerWaitingForFormSubmissionComplete.delete(handler); } /** * Filling the fields again, because a form change was detected by this or * another FormAutofillChild immediately after an autocompletion process * (see handler.fillOnFormChangeData.isWithinDynamicFormChangeThreshold). * * @param {string} focusedId element id of focused element that triggered * the initial autocompletion process * @param {Array} ids element ids of detected fields that will be filled * @param {object} profile profile that was used on first autcompletion process * * @returns {object} filled fields */ fillFieldsOnFormChange(focusedId, ids, profile) { const result = this.fillFields(focusedId, ids, profile, true); const handler = this.#getHandlerByElementId(ids[0]); this.#handlerWaitingForFillOnFormChangeComplete.delete(handler); return result; } /** * Identifies elements that are in the associated form of the passed element. * * @param {Element} element * The element to be identified. * * @returns {FormAutofillHandler} * The autofill handler instance for the form that is associated with the * passed element. */ identifyFieldsWhenFocused(element) { this.debug( `identifyFieldsWhenFocused: ${element.ownerDocument.location?.hostname}` ); const handler = this._fieldDetailsManager.getOrCreateFormHandler(element); if ( this.#handlerWaitingForDetectedComplete.has(handler) || this.#handlerWaitingForFillOnFormChangeComplete.has(handler) || this.#handlerWaitingForFormSubmissionComplete.has(handler) ) { // Bail out if the child process is still waiting for the parent to send a // `onFieldsDetectedComplete` or `onFieldsUpdatedComplete` message, // or a form submission is currently still getting processed. return; } if (handler.fillOnFormChangeData.isWithinDynamicFormChangeThreshold) { // Received the focus event immediately after an autofill action, which was not // initiated by a user but by the site due to the form change. Bail out here, // because we will receive the form-changed-event anyway and should not process the // field detection here, since this would block the second autofill process. return; } // Bail out if there is nothing changed since last time we identified this element // or there is no interested fields. if (handler.hasIdentifiedFields() && !handler.updateFormIfNeeded(element)) { // This is for testing purposes only. It sends a notification to indicate that the // form has been identified and is ready to open the popup. // If new fields are detected, the message will be sent to the parent // once the parent finishes collecting information from sub-frames if they exist. this.sendAsyncMessage("FormAutofill:FieldsIdentified"); const fieldName = handler.getFieldDetailByElement(element)?.fieldName ?? ""; this.showPopupIfEmpty(element, fieldName); } else { const includeIframe = this.browsingContext == this.browsingContext.top; let detectedFields = lazy.FormAutofillHandler.collectFormFieldDetails( handler.form, includeIframe ); // If none of the detected fields are credit card or address fields, // there's no need to notify the parent because nothing will change. if ( !detectedFields.some( fd => lazy.FormAutofillUtils.isCreditCardField(fd.fieldName) || lazy.FormAutofillUtils.isAddressField(fd.fieldName) ) ) { handler.setIdentifiedFieldDetails(detectedFields); return; } this.sendAsyncMessage( "FormAutofill:OnFieldsDetected", detectedFields.map(field => field.toVanillaObject()) ); // Notify the parent about the newly identified fields because // the autofill section information is maintained on the parent side. this.#handlerWaitingForDetectedComplete.add(handler); } } /** * This function is called by the parent when a field is detected in another * frame. The parent uses this function to collect field information from frames * that are part of the same form as the detected field. * * @param {string} focusedBCId * The browsing context ID of the top-level iframe * that contains the detected field. * Note that this value is set only when the current frame is the top-level. * * @returns {Array} * Array of FieldDetail objects of identified fields (including iframes). */ identifyFields(focusedBCId) { const isTop = this.browsingContext == this.browsingContext.top; let element; if (isTop) { // Find the focused iframe element = BrowsingContext.get(focusedBCId).embedderElement; } else { // Ignore form as long as the frame is not the top-level, which means // we can just pick any of the eligible elements to identify. element = lazy.FormAutofillUtils.queryEligibleElements( this.document, true )[0]; } if (!element) { return []; } const handler = this._fieldDetailsManager.getOrCreateFormHandler(element); // We don't have to call 'updateFormIfNeeded' like we do in // 'identifyFieldsWhenFocused' because 'collectFormFieldDetails' doesn't use cached // result. const includeIframe = isTop; const detectedFields = lazy.FormAutofillHandler.collectFormFieldDetails( handler.form, includeIframe ); if (detectedFields.length) { // This actor should receive `onFieldsDetectedComplete`message after // `idenitfyFields` is called this.#handlerWaitingForDetectedComplete.add(handler); } return detectedFields; } showPopupIfEmpty(element, fieldName) { if (element?.value?.length !== 0) { this.debug(`Not opening popup because field is not empty.`); return; } if (fieldName.startsWith("cc-") || AppConstants.platform === "android") { lazy.FormAutofillContent.showPopup(); } } /** * We received a form-submission-detected event because * the page was navigated. */ onPageNavigation() { if (!lazy.FormAutofill.captureOnPageNavigation) { return; } if (this.isFollowingSubmitEvent) { // The next page navigation should be handled as form submission again this.isFollowingSubmitEvent = false; return; } const formSubmissionReason = lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION; const weakIdentifiedForms = this._fieldDetailsManager.getWeakIdentifiedForms(); for (const form of weakIdentifiedForms) { // Disconnected forms are captured by the form removal heuristic if (!form.isConnected) { continue; } this.formSubmitted(form, formSubmissionReason); } } /** * We received a form-submission-detected event because * a form was removed from the DOM after a successful * xhr/fetch request * * @param {Event} form form to be submitted */ onFormRemoval(form) { if (!lazy.FormAutofill.captureOnFormRemoval) { return; } const formSubmissionReason = lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH; this.formSubmitted(form, formSubmissionReason); this.manager.getActor("FormHandler").unregisterFormRemovalInterest(this); } registerPageHide(handler) { // Check whether the section is in an