/* 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"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CreditCard: "resource://gre/modules/CreditCard.sys.mjs", AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs", }); const { FIELD_STATES } = FormAutofillUtils; export class FormAutofillSection { static SHOULD_FOCUS_ON_AUTOFILL = true; #focusedInput = null; #section = null; constructor(section, handler) { this.#section = section; if (!this.isValidSection()) { return; } this.handler = handler; this.filledRecordGUID = null; 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.defineLazyGetter(this, "log", () => FormAutofill.defineLogGetter(this, "FormAutofillHandler") ); this._cacheValue = { allFieldNames: null, matchingSelectOption: null, }; // Identifier used to correlate events relating to the same form this.flowId = Services.uuid.generateUUID().toString(); this.log.debug( "Creating new credit card section with flowId =", this.flowId ); } get fieldDetails() { return this.#section.fieldDetails; } /* * 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 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 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 the profile is needed to be customized for filling * values. * * @param {object} fieldDetail A fieldDetail of the related element. * @param {object} profile The profile to fill. * @returns {string} The value to fill for the given field. */ getFilledValueFromProfile(fieldDetail, profile) { return ( profile[`${fieldDetail.fieldName}-formatted`] || profile[fieldDetail.fieldName] ); } /* * 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.#focusedInput = element; } getFieldDetailByElement(element) { return this.fieldDetails.find( detail => detail.elementWeakRef.get() == element ); } getFieldDetailByName(fieldName) { return this.fieldDetails.find(detail => detail.fieldName == fieldName); } get allFieldNames() { if (!this._cacheValue.allFieldNames) { this._cacheValue.allFieldNames = this.fieldDetails.map( record => record.fieldName ); } return this._cacheValue.allFieldNames; } 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`]; } } } fillFieldValue(element, value) { if (FormAutofillUtils.focusOnAutofill) { element.focus({ preventScroll: true }); } if (HTMLInputElement.isInstance(element)) { element.setUserInput(value); } else if (HTMLSelectElement.isInstance(element)) { // Set the value of the select element so that web event handlers can react accordingly element.value = value; element.dispatchEvent( new element.ownerGlobal.Event("input", { bubbles: true }) ); element.dispatchEvent( new element.ownerGlobal.Event("change", { bubbles: true }) ); } } 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) { if (!this.#focusedInput) { throw new Error("No focused input."); } const focusedDetail = this.getFieldDetailByElement(this.#focusedInput); if (!focusedDetail) { throw new Error("No fieldDetail for the focused input."); } if (!(await this.prepareFillingProfile(profile))) { this.log.debug("profile cannot be filled"); return false; } this.filledRecordGUID = profile.guid; for (const 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 const 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. const value = this.getFilledValueFromProfile(fieldDetail, profile); 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 == this.#focusedInput || (element != this.#focusedInput && (!element.value || element.value == element.defaultValue)) || this.handler.getFilledStateByElement(element) == FIELD_STATES.AUTO_FILLED ) { this.fillFieldValue(element, value); this.handler.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; this.fillFieldValue(element, option.value); } // Autofill highlight appears regardless if value is changed or not this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } } this.#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 (const fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); // Skip the field if it is null or readonly or disabled if (!FormAutofillUtils.isFieldAutofillable(element)) { continue; } let value = profile[`${fieldDetail.fieldName}-formatted`] || profile[fieldDetail.fieldName] || ""; if (HTMLSelectElement.isInstance(element)) { // Unlike text input, select element is always previewed even if // the option is already selected. if (value) { const cache = this._cacheValue.matchingSelectOption.get(element) ?? {}; const option = cache[value]?.get(); value = option?.text ?? ""; } } 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.handler.changeFieldState( fieldDetail, value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL ); } } /** * Clear a previously autofilled field in this section */ clearFilled(fieldDetail) { lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, { fieldName: fieldDetail.fieldName, }); let isAutofilled = false; const 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 }) ); } } export class FormAutofillAddressSection extends FormAutofillSection { constructor(fieldDetails, handler) { super(fieldDetails, handler); if (!this.isValidSection()) { return; } this._cacheValue.oneLineStreetAddress = null; lazy.AutofillTelemetry.recordDetectedSectionCount(this); lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); } isValidSection() { const fields = new Set(this.fieldDetails.map(f => f.fieldName)); return fields.size >= 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. this.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 = ""; } } } } } export class FormAutofillCreditCardSection extends FormAutofillSection { /** * Credit Card Section Constructor * * @param {object} fieldDetails * The fieldDetail objects for the fields in this section * @param {object} handler * The FormAutofillHandler responsible for this section */ constructor(fieldDetails, handler) { super(fieldDetails, handler); if (!this.isValidSection()) { return; } lazy.AutofillTelemetry.recordDetectedSectionCount(this); lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); // Check whether the section is in an