/* 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"; this.EXPORTED_SYMBOLS = ["FormAutofillHandler"]; const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://formautofill/FormAutofillUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "FormAutofillHeuristics", "resource://formautofill/FormAutofillHeuristics.jsm"); this.log = null; FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]); /** * Handles profile autofill for a DOM Form element. * @param {FormLike} form Form that need to be auto filled */ function FormAutofillHandler(form) { this.form = form; this.fieldDetails = []; this.winUtils = this.form.rootElement.ownerGlobal.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils); this.address = { /** * Similar to the `fieldDetails` above but contains address fields only. */ fieldDetails: [], /** * String of the filled address' guid. */ filledRecordGUID: null, }; this.creditCard = { /** * Similar to the `fieldDetails` above but contains credit card fields only. */ fieldDetails: [], /** * String of the filled creditCard's guid. */ filledRecordGUID: null, }; this._cacheValue = { allFieldNames: null, oneLineStreetAddress: null, matchingSelectOption: null, }; } FormAutofillHandler.prototype = { /** * DOM Form element to which this object is attached. */ form: null, _formFieldCount: 0, /** * Array of collected data about relevant form fields. Each item is an object * storing the identifying details of the field and a reference to the * originally associated element from the form. * * The "section", "addressType", "contactType", and "fieldName" values are * used to identify the exact field when the serializable data is received * from the backend. There cannot be multiple fields which have * the same exact combination of these values. * * A direct reference to the associated element cannot be sent to the user * interface because processing may be done in the parent process. */ fieldDetails: null, /** * Subcategory of handler that contains address related data. */ address: null, /** * Subcategory of handler that contains credit card related data. */ creditCard: null, /** * A WindowUtils reference of which Window the form belongs */ winUtils: null, /** * Enum for form autofill MANUALLY_MANAGED_STATES values */ fieldStateEnum: { // not themed NORMAL: null, // highlighted AUTO_FILLED: "-moz-autofill", // highlighted && grey color text PREVIEW: "-moz-autofill-preview", }, get isFormChangedSinceLastCollection() { // When the number of form controls is the same with last collection, it // can be recognized as there is no element changed. However, we should // improve the function to detect the element changes. e.g. a tel field // is changed from type="hidden" to type="tel". return this._formFieldCount != this.form.elements.length; }, /** * Time in milliseconds since epoch when a user started filling in the form. */ timeStartedFillingMS: null, /** * Set fieldDetails from the form about fields that can be autofilled. * * @param {boolean} allowDuplicates * true to remain any duplicated field details otherwise to remove the * duplicated ones. * @returns {Array} The valid address and credit card details. */ collectFormFields(allowDuplicates = false) { this._cacheValue.allFieldNames = null; this._formFieldCount = this.form.elements.length; let fieldDetails = FormAutofillHeuristics.getFormInfo(this.form, allowDuplicates); this.fieldDetails = fieldDetails ? fieldDetails : []; log.debug("Collected details on", this.fieldDetails.length, "fields"); this.address.fieldDetails = this.fieldDetails.filter( detail => FormAutofillUtils.isAddressField(detail.fieldName) ); this.creditCard.fieldDetails = this.fieldDetails.filter( detail => FormAutofillUtils.isCreditCardField(detail.fieldName) ); if (this.address.fieldDetails.length < FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD) { log.debug("Ignoring address related fields since it has only", this.address.fieldDetails.length, "field(s)"); this.address.fieldDetails = []; } if (!this.creditCard.fieldDetails.some(i => i.fieldName == "cc-number")) { log.debug("Ignoring credit card related fields since it's without credit card number field"); this.creditCard.fieldDetails = []; } let validDetails = Array.of(...(this.address.fieldDetails), ...(this.creditCard.fieldDetails)); for (let detail of validDetails) { let input = detail.elementWeakRef.get(); if (!input) { continue; } input.addEventListener("input", this); } return validDetails; }, getFieldDetailByName(fieldName) { return this.fieldDetails.find(detail => detail.fieldName == fieldName); }, getFieldDetailByElement(element) { return this.fieldDetails.find( detail => detail.elementWeakRef.get() == element ); }, getFieldDetailsByElement(element) { let fieldDetail = this.getFieldDetailByElement(element); if (!fieldDetail) { return []; } if (FormAutofillUtils.isAddressField(fieldDetail.fieldName)) { return this.address.fieldDetails; } if (FormAutofillUtils.isCreditCardField(fieldDetail.fieldName)) { return this.creditCard.fieldDetails; } return []; }, get allFieldNames() { if (!this._cacheValue.allFieldNames) { this._cacheValue.allFieldNames = this.fieldDetails.map(record => record.fieldName); } return this._cacheValue.allFieldNames; }, _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 && (streetAddressDetail.elementWeakRef.get() instanceof Ci.nsIDOMHTMLInputElement)) { 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 (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"]; } } }, _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 (!(element instanceof Ci.nsIDOMHTMLSelectElement)) { 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]; } } }, getAdaptedProfiles(originalProfiles) { for (let profile of originalProfiles) { this._addressTransformer(profile); this._telTransformer(profile); this._matchSelectOptions(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. * @param {Object} focusedInput * A focused input element needed to determine the address or credit * card field. */ async autofillFormFields(profile, focusedInput) { let focusedDetail = this.fieldDetails.find( detail => detail.elementWeakRef.get() == focusedInput ); let targetSet; if (FormAutofillUtils.isCreditCardField(focusedDetail.fieldName)) { // When Master Password is enabled by users, the decryption process // should prompt Master Password dialog to get the decrypted credit // card number. Otherwise, the number can be decrypted with the default // password. if (profile["cc-number-encrypted"]) { let decrypted = await this._decrypt(profile["cc-number-encrypted"], true); if (!decrypted) { // Early return if the decrypted is empty or undefined return; } profile["cc-number"] = decrypted; } targetSet = this.creditCard; } else if (FormAutofillUtils.isAddressField(focusedDetail.fieldName)) { targetSet = this.address; } else { throw new Error("Unknown form fields"); } log.debug("profile in autofillFormFields:", profile); targetSet.filledRecordGUID = profile.guid; for (let fieldDetail of targetSet.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 (element instanceof Ci.nsIDOMHTMLInputElement && 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. if (element == focusedInput || (element != focusedInput && !element.value)) { element.setUserInput(value); this.changeFieldState(fieldDetail, "AUTO_FILLED"); continue; } } if (element instanceof Ci.nsIDOMHTMLSelectElement) { 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.dispatchEvent(new element.ownerGlobal.UIEvent("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, "AUTO_FILLED"); } } // Handle the highlight style resetting caused by user's correction afterward. log.debug("register change handler for filled form:", this.form); const onChangeHandler = e => { let hasFilledFields; if (!e.isTrusted) { return; } for (let fieldDetail of targetSet.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); if (!element) { return; } if (e.target == element || (e.target == element.form && e.type == "reset")) { this.changeFieldState(fieldDetail, "NORMAL"); } hasFilledFields |= (fieldDetail.state == "AUTO_FILLED"); } // Unregister listeners and clear guid once no field is in AUTO_FILLED state. if (!hasFilledFields) { this.form.rootElement.removeEventListener("input", onChangeHandler); this.form.rootElement.removeEventListener("reset", onChangeHandler); targetSet.filledRecordGUID = null; } }; this.form.rootElement.addEventListener("input", onChangeHandler); this.form.rootElement.addEventListener("reset", onChangeHandler); }, /** * Populates result to the preview layers with given profile. * * @param {Object} profile * A profile to be previewed with * @param {Object} focusedInput * A focused input element for determining credit card or address fields. */ previewFormFields(profile, focusedInput) { log.debug("preview profile in autofillFormFields:", profile); // Always show the decrypted credit card number when Master Password is // disabled. if (profile["cc-number-decrypted"]) { profile["cc-number"] = profile["cc-number-decrypted"]; } let fieldDetails = this.getFieldDetailsByElement(focusedInput); for (let fieldDetail of fieldDetails) { let element = fieldDetail.elementWeakRef.get(); let value = profile[fieldDetail.fieldName] || ""; // Skip the field that is null if (!element) { continue; } if (element instanceof Ci.nsIDOMHTMLSelectElement) { // 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 ? "PREVIEW" : "NORMAL"); } }, /** * Clear preview text and background highlight of all fields. * * @param {Object} focusedInput * A focused input element for determining credit card or address fields. */ clearPreviewedFormFields(focusedInput) { log.debug("clear previewed fields in:", this.form); let fieldDetails = this.getFieldDetailsByElement(focusedInput); for (let fieldDetail of 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 === "AUTO_FILLED") { continue; } this.changeFieldState(fieldDetail, "NORMAL"); } }, /** * 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.fieldStateEnum)) { log.warn(fieldDetail.fieldName, "is trying to change to an invalid state"); return; } for (let [state, mmStateValue] of Object.entries(this.fieldStateEnum)) { // 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); } } fieldDetail.state = nextState; }, _isAddressRecordCreatable(record) { 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; }, _isCreditCardRecordCreatable(record) { return record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"]); }, /** * Return the records that is converted from address/creditCard fieldDetails and * only valid form records are included. * * @returns {Object} * Consists of two record objects: address, creditCard. Each one can * be omitted if there's no valid fields. 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. */ createRecords() { let data = {}; let target = []; if (FormAutofillUtils.isAutofillAddressesEnabled) { target.push("address"); } if (FormAutofillUtils.isAutofillCreditCardsEnabled) { target.push("creditCard"); } target.forEach(type => { let details = this[type].fieldDetails; if (!details || details.length == 0) { return; } data[type] = { guid: this[type].filledRecordGUID, record: {}, untouchedFields: [], }; details.forEach(detail => { let element = detail.elementWeakRef.get(); // Remove the unnecessary spaces let value = element && element.value.trim(); // Try to abbreviate the value of select element. if (type == "address" && detail.fieldName == "address-level1" && element instanceof Ci.nsIDOMHTMLSelectElement) { // 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 data[type].record[detail.fieldName] = ""; return; } let text = element.selectedOptions[0].text.trim(); value = FormAutofillUtils.getAbbreviatedStateName([value, text]) || text; } if (!value) { // Keep the property and preserve more information for updating data[type].record[detail.fieldName] = ""; return; } data[type].record[detail.fieldName] = value; if (detail.state == "AUTO_FILLED") { data[type].untouchedFields.push(detail.fieldName); } }); }); this._normalizeAddress(data.address); if (data.address && !this._isAddressRecordCreatable(data.address.record)) { log.debug("No address record saving since there are only", Object.keys(data.address.record).length, "usable fields"); delete data.address; } if (data.creditCard && !this._isCreditCardRecordCreatable(data.creditCard.record)) { log.debug("No credit card record saving since card number is invalid"); delete data.creditCard; } // If both address and credit card exists, skip this metrics because it not a // general case and each specific histogram might contains insufficient data set. if (data.address && data.creditCard) { this.timeStartedFillingMS = null; } return data; }, _normalizeAddress(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 = ""; } } } }, async _decrypt(cipherText, reauth) { return new Promise((resolve) => { Services.cpmm.addMessageListener("FormAutofill:DecryptedString", function getResult(result) { Services.cpmm.removeMessageListener("FormAutofill:DecryptedString", getResult); resolve(result.data); }); Services.cpmm.sendAsyncMessage("FormAutofill:GetDecryptedString", {cipherText, reauth}); }); }, handleEvent(event) { switch (event.type) { case "input": if (!event.isTrusted) { return; } for (let detail of this.fieldDetails) { let input = detail.elementWeakRef.get(); if (!input) { continue; } input.removeEventListener("input", this); } this.timeStartedFillingMS = Date.now(); break; } }, };