/* 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/. */ /* * Defines a handler object to represent forms that autofill can handle. */ "use strict"; var EXPORTED_SYMBOLS = ["FormAutofillHandler"]; const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { AppConstants } = ChromeUtils.import( "resource://gre/modules/AppConstants.jsm" ); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); const { FormAutofill } = ChromeUtils.import( "resource://formautofill/FormAutofill.jsm" ); ChromeUtils.defineModuleGetter( this, "FormAutofillUtils", "resource://formautofill/FormAutofillUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "FormAutofillHeuristics", "resource://formautofill/FormAutofillHeuristics.jsm" ); ChromeUtils.defineModuleGetter( this, "FormLikeFactory", "resource://gre/modules/FormLikeFactory.jsm" ); const formFillController = Cc[ "@mozilla.org/satchel/form-fill-controller;1" ].getService(Ci.nsIFormFillController); XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => { const brandShortName = FormAutofillUtils.brandBundle.GetStringFromName( "brandShortName" ); // The string name for Mac is changed because the value needed updating. const platform = AppConstants.platform.replace("macosx", "macos"); return FormAutofillUtils.stringBundle.formatStringFromName( `useCreditCardPasswordPrompt.${platform}`, [brandShortName] ); }); XPCOMUtils.defineLazyModuleGetters(this, { CreditCard: "resource://gre/modules/CreditCard.jsm", }); XPCOMUtils.defineLazyServiceGetters(this, { gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"], }); this.log = null; FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); const { FIELD_STATES } = FormAutofillUtils; class FormAutofillSection { constructor(fieldDetails, winUtils) { this.fieldDetails = fieldDetails; this.filledRecordGUID = null; this.winUtils = winUtils; /** * Enum for form autofill MANUALLY_MANAGED_STATES values */ this._FIELD_STATE_ENUM = { // not themed [FIELD_STATES.NORMAL]: null, // highlighted [FIELD_STATES.AUTO_FILLED]: "autofill", // highlighted && grey color text [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", }; if (!this.isValidSection()) { this.fieldDetails = []; log.debug( `Ignoring ${this.constructor.name} related fields since it is an invalid section` ); } this._cacheValue = { allFieldNames: null, matchingSelectOption: null, }; } /* * Examine the section is a valid section or not based on its fieldDetails or * other information. This method must be overrided. * * @returns {boolean} True for a valid section, otherwise false * */ isValidSection() { throw new TypeError("isValidSection method must be overrided"); } /* * Examine the section is an enabled section type or not based on its * preferences. This method must be overrided. * * @returns {boolean} True for an enabled section type, otherwise false * */ isEnabled() { throw new TypeError("isEnabled method must be overrided"); } /* * Examine the section is createable for storing the profile. This method * must be overrided. * * @param {Object} record The record for examining createable * @returns {boolean} True for the record is createable, otherwise false * */ isRecordCreatable(record) { throw new TypeError("isRecordCreatable method must be overrided"); } /** * Override this method if the profile is needed to apply some transformers. * * @param {Object} profile * A profile should be converted based on the specific requirement. */ applyTransformers(profile) {} /** * Override this method if the profile is needed to be customized for * previewing values. * * @param {Object} profile * A profile for pre-processing before previewing values. */ preparePreviewProfile(profile) {} /** * Override this method if the profile is needed to be customized for filling * values. * * @param {Object} profile * A profile for pre-processing before filling values. * @returns {boolean} Whether the profile should be filled. */ async prepareFillingProfile(profile) { return true; } /* * Override this methid if any data for `createRecord` is needed to be * normailized before submitting the record. * * @param {Object} profile * A record for normalization. */ normalizeCreatingRecord(data) {} /* * Override this method if there is any field value needs to compute for a * specific case. Return the original value in the default case. * @param {String} value * The original field value. * @param {Object} fieldDetail * A fieldDetail of the related element. * @param {HTMLElement} element * A element for checking converting value. * * @returns {String} * A string of the converted value. */ computeFillingValue(value, fieldName, element) { return value; } set focusedInput(element) { this._focusedDetail = this.getFieldDetailByElement(element); } getFieldDetailByElement(element) { return this.fieldDetails.find( detail => detail.elementWeakRef.get() == element ); } get allFieldNames() { if (!this._cacheValue.allFieldNames) { this._cacheValue.allFieldNames = this.fieldDetails.map( record => record.fieldName ); } return this._cacheValue.allFieldNames; } getFieldDetailByName(fieldName) { return this.fieldDetails.find(detail => detail.fieldName == fieldName); } matchSelectOptions(profile) { if (!this._cacheValue.matchingSelectOption) { this._cacheValue.matchingSelectOption = new WeakMap(); } for (let fieldName in profile) { let fieldDetail = this.getFieldDetailByName(fieldName); if (!fieldDetail) { continue; } let element = fieldDetail.elementWeakRef.get(); if (ChromeUtils.getClassName(element) !== "HTMLSelectElement") { continue; } let cache = this._cacheValue.matchingSelectOption.get(element) || {}; let value = profile[fieldName]; if (cache[value] && cache[value].get()) { continue; } let option = FormAutofillUtils.findSelectOption( element, profile, fieldName ); if (option) { cache[value] = Cu.getWeakReference(option); this._cacheValue.matchingSelectOption.set(element, cache); } else { if (cache[value]) { delete cache[value]; this._cacheValue.matchingSelectOption.set(element, cache); } // Delete the field so the phishing hint won't treat it as a "also fill" // field. delete profile[fieldName]; } } } adaptFieldMaxLength(profile) { for (let key in profile) { let detail = this.getFieldDetailByName(key); if (!detail) { continue; } let element = detail.elementWeakRef.get(); if (!element) { continue; } let maxLength = element.maxLength; if ( maxLength === undefined || maxLength < 0 || profile[key].toString().length <= maxLength ) { continue; } if (maxLength) { switch (typeof profile[key]) { case "string": // If this is an expiration field and our previous // adaptations haven't resulted in a string that is // short enough to satisfy the field length, and the // field is constrained to a length of 5, then we // assume it is intended to hold an expiration of the // form "MM/YY". if (key == "cc-exp" && maxLength == 5) { const month2Digits = ( "0" + profile["cc-exp-month"].toString() ).slice(-2); const year2Digits = profile["cc-exp-year"].toString().slice(-2); profile[key] = `${month2Digits}/${year2Digits}`; } else { profile[key] = profile[key].substr(0, maxLength); } break; case "number": // There's no way to truncate a number smaller than a // single digit. if (maxLength < 1) { maxLength = 1; } // The only numbers we store are expiration month/year, // and if they truncate, we want the final digits, not // the initial ones. profile[key] = profile[key] % Math.pow(10, maxLength); break; default: log.warn( "adaptFieldMaxLength: Don't know how to truncate", typeof profile[key], profile[key] ); } } else { delete profile[key]; } } } getAdaptedProfiles(originalProfiles) { for (let profile of originalProfiles) { this.applyTransformers(profile); } return originalProfiles; } /** * Processes form fields that can be autofilled, and populates them with the * profile provided by backend. * * @param {Object} profile * A profile to be filled in. * @returns {boolean} * True if successful, false if failed */ async autofillFields(profile) { let focusedDetail = this._focusedDetail; if (!focusedDetail) { throw new Error("No fieldDetail for the focused input."); } if (!(await this.prepareFillingProfile(profile))) { log.debug("profile cannot be filled", profile); return false; } log.debug("profile in autofillFields:", profile); let focusedInput = focusedDetail.elementWeakRef.get(); this.filledRecordGUID = profile.guid; for (let fieldDetail of this.fieldDetails) { // Avoid filling field value in the following cases: // 1. a non-empty input field for an unfocused input // 2. the invalid value set // 3. value already chosen in select element let element = fieldDetail.elementWeakRef.get(); if (!element) { continue; } element.previewValue = ""; let value = profile[fieldDetail.fieldName]; if (ChromeUtils.getClassName(element) === "HTMLInputElement" && value) { // For the focused input element, it will be filled with a valid value // anyway. // For the others, the fields should be only filled when their values // are empty or are the result of an earlier auto-fill. if ( element == focusedInput || (element != focusedInput && !element.value) || fieldDetail.state == FIELD_STATES.AUTO_FILLED ) { element.focus({ preventScroll: true }); element.setUserInput(value); this._changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } } else if (ChromeUtils.getClassName(element) === "HTMLSelectElement") { let cache = this._cacheValue.matchingSelectOption.get(element) || {}; let option = cache[value] && cache[value].get(); if (!option) { continue; } // Do not change value or dispatch events if the option is already selected. // Use case for multiple select is not considered here. if (!option.selected) { option.selected = true; element.focus({ preventScroll: true }); element.dispatchEvent( new element.ownerGlobal.Event("input", { bubbles: true }) ); element.dispatchEvent( new element.ownerGlobal.Event("change", { bubbles: true }) ); } // Autofill highlight appears regardless if value is changed or not this._changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } } focusedInput.focus({ preventScroll: true }); return true; } /** * Populates result to the preview layers with given profile. * * @param {Object} profile * A profile to be previewed with */ previewFormFields(profile) { log.debug("preview profile: ", profile); this.preparePreviewProfile(profile); for (let fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); let value = profile[fieldDetail.fieldName] || ""; // Skip the field that is null if (!element) { continue; } if (ChromeUtils.getClassName(element) === "HTMLSelectElement") { // Unlike text input, select element is always previewed even if // the option is already selected. if (value) { let cache = this._cacheValue.matchingSelectOption.get(element) || {}; let option = cache[value] && cache[value].get(); if (option) { value = option.text || ""; } else { value = ""; } } } else if (element.value) { // Skip the field if it already has text entered. continue; } element.previewValue = value; this._changeFieldState( fieldDetail, value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL ); } } /** * Clear preview text and background highlight of all fields. */ clearPreviewedFormFields() { log.debug("clear previewed fields in:", this.form); for (let fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); if (!element) { log.warn(fieldDetail.fieldName, "is unreachable"); continue; } element.previewValue = ""; // We keep the state if this field has // already been auto-filled. if (fieldDetail.state == FIELD_STATES.AUTO_FILLED) { continue; } this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } } /** * Clear value and highlight style of all filled fields. */ clearPopulatedForm() { for (let fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); if (!element) { log.warn(fieldDetail.fieldName, "is unreachable"); continue; } // Only reset value for input element. if ( fieldDetail.state == FIELD_STATES.AUTO_FILLED && ChromeUtils.getClassName(element) === "HTMLInputElement" ) { element.setUserInput(""); } } } /** * Change the state of a field to correspond with different presentations. * * @param {Object} fieldDetail * A fieldDetail of which its element is about to update the state. * @param {string} nextState * Used to determine the next state */ _changeFieldState(fieldDetail, nextState) { let element = fieldDetail.elementWeakRef.get(); if (!element) { log.warn(fieldDetail.fieldName, "is unreachable while changing state"); return; } if (!(nextState in this._FIELD_STATE_ENUM)) { log.warn( fieldDetail.fieldName, "is trying to change to an invalid state" ); return; } if (fieldDetail.state == nextState) { return; } for (let [state, mmStateValue] of Object.entries(this._FIELD_STATE_ENUM)) { // The NORMAL state is simply the absence of other manually // managed states so we never need to add or remove it. if (!mmStateValue) { continue; } if (state == nextState) { this.winUtils.addManuallyManagedState(element, mmStateValue); } else { this.winUtils.removeManuallyManagedState(element, mmStateValue); } } if (nextState == FIELD_STATES.AUTO_FILLED) { element.addEventListener("input", this, { mozSystemGroup: true }); } fieldDetail.state = nextState; } resetFieldStates() { for (let fieldDetail of this.fieldDetails) { const element = fieldDetail.elementWeakRef.get(); element.removeEventListener("input", this, { mozSystemGroup: true }); this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } this.filledRecordGUID = null; } isFilled() { return !!this.filledRecordGUID; } /** * Return the record that is converted from `fieldDetails` and only valid * form record is included. * * @returns {Object|null} * A record object consists of three properties: * - guid: The id of the previously-filled profile or null if omitted. * - record: A valid record converted from details with trimmed result. * - untouchedFields: Fields that aren't touched after autofilling. * Return `null` for any uncreatable or invalid record. */ createRecord() { let details = this.fieldDetails; if (!this.isEnabled() || !details || !details.length) { return null; } let data = { guid: this.filledRecordGUID, record: {}, untouchedFields: [], }; if (this.flowId) { data.flowId = this.flowId; } details.forEach(detail => { let element = detail.elementWeakRef.get(); // Remove the unnecessary spaces let value = element && element.value.trim(); value = this.computeFillingValue(value, detail, element); if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) { // Keep the property and preserve more information for updating data.record[detail.fieldName] = ""; return; } data.record[detail.fieldName] = value; if (detail.state == FIELD_STATES.AUTO_FILLED) { data.untouchedFields.push(detail.fieldName); } }); this.normalizeCreatingRecord(data); if (!this.isRecordCreatable(data.record)) { return null; } return data; } handleEvent(event) { switch (event.type) { case "input": { if (!event.isTrusted) { return; } const target = event.target; const targetFieldDetail = this.getFieldDetailByElement(target); const isCreditCardField = FormAutofillUtils.isCreditCardField( targetFieldDetail.fieldName ); // If the user manually blanks a credit card field, then // we want the popup to be activated. if ( ChromeUtils.getClassName(target) !== "HTMLSelectElement" && isCreditCardField && target.value === "" ) { formFillController.showPopup(); } if (targetFieldDetail.state == FIELD_STATES.NORMAL) { return; } this._changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); if (isCreditCardField) { Services.telemetry.recordEvent( "creditcard", "filled_modified", "cc_form", this.flowId, { field_name: targetFieldDetail.fieldName, } ); } let isAutofilled = false; let dimFieldDetails = []; for (const fieldDetail of this.fieldDetails) { const element = fieldDetail.elementWeakRef.get(); if (ChromeUtils.getClassName(element) === "HTMLSelectElement") { // Dim fields are those we don't attempt to revert their value // when clear the target set, such as