/* 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", "FormAutofillCreditCardSection"]; const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); const { FormAutofill } = ChromeUtils.import( "resource://autofill/FormAutofill.jsm" ); const { FormAutofillUtils } = ChromeUtils.import( "resource://autofill/FormAutofillUtils.jsm" ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CreditCard: "resource://gre/modules/CreditCard.sys.mjs", FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", }); XPCOMUtils.defineLazyModuleGetters(lazy, { AutofillTelemetry: "resource://autofill/AutofillTelemetry.jsm", FormAutofillHeuristics: "resource://autofill/FormAutofillHeuristics.jsm", }); const formFillController = Cc[ "@mozilla.org/satchel/form-fill-controller;1" ].getService(Ci.nsIFormFillController); XPCOMUtils.defineLazyGetter(lazy, "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.defineLazyGetter(lazy, "log", () => FormAutofill.defineLogGetter(lazy, 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 = []; lazy.log.debug( `Ignoring ${this.constructor.name} related fields since it is an invalid section` ); } this._cacheValue = { allFieldNames: null, matchingSelectOption: null, }; // Identifier used to correlate events relating to the same form this.flowId = Services.uuid.generateUUID().toString(); lazy.log.debug( "Creating new credit card section with flowId =", this.flowId ); lazy.AutofillTelemetry.recordDetectedSectionCount(this); lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); } /* * 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 overridden"); } /** * 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 method if any data for `createRecord` is needed to be * normalized before submitting the record. * * @param {Object} profile * A record for normalization. */ createNormalizedRecord(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 (!HTMLSelectElement.isInstance(element)) { 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 if (key == "cc-number") { // We want to show the last four digits of credit card so that // the masked credit card previews correctly and appears correctly // in the autocomplete menu profile[key] = profile[key].substr( profile[key].length - maxLength ); } 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: } } else { delete profile[key]; delete profile[`${key}-formatted`]; } } } 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))) { lazy.log.debug("profile cannot be filled"); return false; } 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(); // Skip the field if it is null or readonly or disabled if (!FormAutofillUtils.isFieldAutofillable(element)) { continue; } element.previewValue = ""; // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field // that is generated when presentation ready data doesn't fit into the autofilling element. // For example, autofilling expiration month into an input element will not work as expected if // the month is less than 10, since the input is expected a zero-padded string. // See Bug 1722941 for follow up. let value = profile[`${fieldDetail.fieldName}-formatted`] || profile[fieldDetail.fieldName]; // Bug 1688607: The transform function allows us to handle the multiple credit card number fields case if (fieldDetail.transform) { value = fieldDetail.transform(value); } if (HTMLInputElement.isInstance(element) && 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 their values are equal to the site prefill value // or are the result of an earlier auto-fill. if ( element == focusedInput || (element != focusedInput && (!element.value || element.value == element.defaultValue)) || fieldDetail.state == FIELD_STATES.AUTO_FILLED ) { element.focus({ preventScroll: true }); element.setUserInput(value); this._changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } } else if (HTMLSelectElement.isInstance(element)) { 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 }); // Set the value of the select element so that web event handlers can react accordingly element.value = option.value; 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 }); lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, { profile, }); return true; } /** * Populates result to the preview layers with given profile. * * @param {object} profile * A profile to be previewed with */ previewFormFields(profile) { this.preparePreviewProfile(profile); for (let fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); let value = profile[`${fieldDetail.fieldName}-formatted`] || profile[fieldDetail.fieldName] || ""; // Skip the field if it is null or readonly or disabled if (!FormAutofillUtils.isFieldAutofillable(element)) { continue; } if (HTMLSelectElement.isInstance(element)) { // 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 && element.value != element.defaultValue) { // Skip the field if the user has already entered text and that text is not the site prefilled value. continue; } element.previewValue = value; this._changeFieldState( fieldDetail, value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL ); } } /** * Clear preview text and background highlight of all fields. */ clearPreviewedFormFields() { lazy.log.debug("clear previewed fields"); for (let fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); if (!element) { lazy.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) { lazy.log.warn(fieldDetail.fieldName, "is unreachable"); continue; } if (fieldDetail.state == FIELD_STATES.AUTO_FILLED) { if (HTMLInputElement.isInstance(element)) { element.setUserInput(""); } else if (HTMLSelectElement.isInstance(element)) { // If we can't find a selected option, then we should just reset to the first option's value this._resetSelectElementValue(element); } } } } /** * 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) { lazy.log.warn( fieldDetail.fieldName, "is unreachable while changing state" ); return; } if (!(nextState in this._FIELD_STATE_ENUM)) { lazy.log.warn( fieldDetail.fieldName, "is trying to change to an invalid state" ); return; } if (fieldDetail.state == nextState) { return; } let nextStateValue = null; 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) { nextStateValue = mmStateValue; } else { this.winUtils.removeManuallyManagedState(element, mmStateValue); } } if (nextStateValue) { this.winUtils.addManuallyManagedState(element, nextStateValue); } 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; } /** * Condenses multiple credit card number fields into one fieldDetail * in order to submit the credit card record correctly. * * @param {Array.} condensedDetails * An array of fieldDetails * @memberof FormAutofillSection */ _condenseMultipleCCNumberFields(condensedDetails) { let countOfCCNumbers = 0; // We ignore the cases where there are more than or less than four credit card number // fields in a form as this is not a valid case for filling the credit card number. for (let i = condensedDetails.length - 1; i >= 0; i--) { if (condensedDetails[i].fieldName == "cc-number") { countOfCCNumbers++; if (countOfCCNumbers == 4) { countOfCCNumbers = 0; condensedDetails[i].fieldValue = condensedDetails[i].elementWeakRef.get()?.value + condensedDetails[i + 1].elementWeakRef.get()?.value + condensedDetails[i + 2].elementWeakRef.get()?.value + condensedDetails[i + 3].elementWeakRef.get()?.value; condensedDetails.splice(i + 1, 3); } } else { countOfCCNumbers = 0; } } } /** * 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: [], section: this, }; if (this.flowId) { data.flowId = this.flowId; } let condensedDetails = this.fieldDetails; // TODO: This is credit card specific code... this._condenseMultipleCCNumberFields(condensedDetails); condensedDetails.forEach(detail => { let element = detail.elementWeakRef.get(); // Remove the unnecessary spaces let value = detail.fieldValue ?? (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.createNormalizedRecord(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 ( !HTMLSelectElement.isInstance(target) && isCreditCardField && target.value === "" ) { formFillController.showPopup(); } if (targetFieldDetail.state == FIELD_STATES.NORMAL) { return; } this._changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); lazy.AutofillTelemetry.recordFormInteractionEvent( "filled_modified", this, { fieldName: targetFieldDetail.fieldName, } ); let isAutofilled = false; let dimFieldDetails = []; for (const fieldDetail of this.fieldDetails) { const element = fieldDetail.elementWeakRef.get(); if (HTMLSelectElement.isInstance(element)) { // Dim fields are those we don't attempt to revert their value // when clear the target set, such as element to its selected option or the first option if there is none selected. * * @param {HTMLElement} element * @memberof FormAutofillSection */ _resetSelectElementValue(element) { if (!element.options.length) { return; } let selected = [...element.options].find(option => option.hasAttribute("selected") ); element.value = selected ? selected.value : element.options[0].value; element.dispatchEvent( new element.ownerGlobal.Event("input", { bubbles: true }) ); element.dispatchEvent( new element.ownerGlobal.Event("change", { bubbles: true }) ); } } class FormAutofillAddressSection extends FormAutofillSection { constructor(fieldDetails, winUtils) { super(fieldDetails, winUtils); this._cacheValue.oneLineStreetAddress = null; } isValidSection() { return ( this.fieldDetails.length >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD ); } isEnabled() { return FormAutofill.isAutofillAddressesEnabled; } isRecordCreatable(record) { if ( record.country && !FormAutofill.isAutofillAddressesAvailableInCountry(record.country) ) { // We don't want to save data in the wrong fields due to not having proper // heuristic regexes in countries we don't yet support. lazy.log.warn( "isRecordCreatable: Country not supported:", record.country ); return false; } let hasName = 0; let length = 0; for (let key of Object.keys(record)) { if (!record[key]) { continue; } if (FormAutofillUtils.getCategoryFromFieldName(key) == "name") { hasName = 1; continue; } length++; } return length + hasName >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; } _getOneLineStreetAddress(address) { if (!this._cacheValue.oneLineStreetAddress) { this._cacheValue.oneLineStreetAddress = {}; } if (!this._cacheValue.oneLineStreetAddress[address]) { this._cacheValue.oneLineStreetAddress[ address ] = FormAutofillUtils.toOneLineAddress(address); } return this._cacheValue.oneLineStreetAddress[address]; } addressTransformer(profile) { if (profile["street-address"]) { // "-moz-street-address-one-line" is used by the labels in // ProfileAutoCompleteResult. profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress( profile["street-address"] ); let streetAddressDetail = this.getFieldDetailByName("street-address"); if ( streetAddressDetail && HTMLInputElement.isInstance(streetAddressDetail.elementWeakRef.get()) ) { profile["street-address"] = profile["-moz-street-address-one-line"]; } let waitForConcat = []; for (let f of ["address-line3", "address-line2", "address-line1"]) { waitForConcat.unshift(profile[f]); if (this.getFieldDetailByName(f)) { if (waitForConcat.length > 1) { profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat); } waitForConcat = []; } } } } /** * Replace tel with tel-national if tel violates the input element's * restriction. * * @param {object} profile * A profile to be converted. */ telTransformer(profile) { if (!profile.tel || !profile["tel-national"]) { return; } let detail = this.getFieldDetailByName("tel"); if (!detail) { return; } let element = detail.elementWeakRef.get(); let _pattern; let testPattern = str => { if (!_pattern) { // The pattern has to match the entire value. _pattern = new RegExp("^(?:" + element.pattern + ")$", "u"); } return _pattern.test(str); }; if (element.pattern) { if (testPattern(profile.tel)) { return; } } else if (element.maxLength) { if ( detail._reason == "autocomplete" && profile.tel.length <= element.maxLength ) { return; } } if (detail._reason != "autocomplete") { // Since we only target people living in US and using en-US websites in // MVP, it makes more sense to fill `tel-national` instead of `tel` // if the field is identified by heuristics and no other clues to // determine which one is better. // TODO: [Bug 1407545] This should be improved once more countries are // supported. profile.tel = profile["tel-national"]; } else if (element.pattern) { if (testPattern(profile["tel-national"])) { profile.tel = profile["tel-national"]; } } else if (element.maxLength) { if (profile["tel-national"].length <= element.maxLength) { profile.tel = profile["tel-national"]; } } } /* * Apply all address related transformers. * * @param {Object} profile * A profile for adjusting address related value. * @override */ applyTransformers(profile) { this.addressTransformer(profile); this.telTransformer(profile); this.matchSelectOptions(profile); this.adaptFieldMaxLength(profile); } computeFillingValue(value, fieldDetail, element) { // Try to abbreviate the value of select element. if ( fieldDetail.fieldName == "address-level1" && HTMLSelectElement.isInstance(element) ) { // Don't save the record when the option value is empty *OR* there // are multiple options being selected. The empty option is usually // assumed to be default along with a meaningless text to users. if (!value || element.selectedOptions.length != 1) { // Keep the property and preserve more information for address updating value = ""; } else { let text = element.selectedOptions[0].text.trim(); value = FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text; } } return value; } createNormalizedRecord(address) { if (!address) { return; } // Normalize Country if (address.record.country) { let detail = this.getFieldDetailByName("country"); // Try identifying country field aggressively if it doesn't come from // @autocomplete. if (detail._reason != "autocomplete") { let countryCode = FormAutofillUtils.identifyCountryCode( address.record.country ); if (countryCode) { address.record.country = countryCode; } } } // Normalize Tel FormAutofillUtils.compressTel(address.record); if (address.record.tel) { let allTelComponentsAreUntouched = Object.keys(address.record) .filter( field => FormAutofillUtils.getCategoryFromFieldName(field) == "tel" ) .every(field => address.untouchedFields.includes(field)); if (allTelComponentsAreUntouched) { // No need to verify it if none of related fields are modified after autofilling. if (!address.untouchedFields.includes("tel")) { address.untouchedFields.push("tel"); } } else { let strippedNumber = address.record.tel.replace(/[\s\(\)-]/g, ""); // Remove "tel" if it contains invalid characters or the length of its // number part isn't between 5 and 15. // (The maximum length of a valid number in E.164 format is 15 digits // according to https://en.wikipedia.org/wiki/E.164 ) if (!/^(\+?)[\da-zA-Z]{5,15}$/.test(strippedNumber)) { address.record.tel = ""; } } } } } class FormAutofillCreditCardSection extends FormAutofillSection { /** * Credit Card Section Constructor * * @param {object} fieldDetails * The fieldDetail objects for the fields in this section * @param {object} winUtils * A WindowUtils reference for the Window the section appears in * @param {object} handler * The FormAutofillHandler responsible for this section */ constructor(fieldDetails, winUtils, handler) { super(fieldDetails, winUtils); this.handler = handler; if (!this.isValidSection()) { return; } // Check whether the section is in an