diff options
Diffstat (limited to '')
-rw-r--r-- | browser/extensions/formautofill/FormAutofillParent.jsm | 878 |
1 files changed, 878 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/FormAutofillParent.jsm b/browser/extensions/formautofill/FormAutofillParent.jsm new file mode 100644 index 0000000000..47a965e7f5 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillParent.jsm @@ -0,0 +1,878 @@ +/* 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/. */ + +/* + * Implements a service used to access storage and communicate with content. + * + * A "fields" array is used to communicate with FormAutofillContent. Each item + * represents a single input field in the content page as well as its + * @autocomplete properties. The schema is as below. Please refer to + * FormAutofillContent.js for more details. + * + * [ + * { + * section, + * addressType, + * contactType, + * fieldName, + * value, + * index + * }, + * { + * // ... + * } + * ] + */ + +"use strict"; + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +var EXPORTED_SYMBOLS = ["FormAutofillParent", "FormAutofillStatus"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + CreditCard: "resource://gre/modules/CreditCard.jsm", + FormAutofillPreferences: + "resource://formautofill/FormAutofillPreferences.jsm", + FormAutofillDoorhanger: "resource://formautofill/FormAutofillDoorhanger.jsm", + FormAutofillUtils: "resource://formautofill/FormAutofillUtils.jsm", + OSKeyStore: "resource://gre/modules/OSKeyStore.jsm", +}); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +const { + ENABLED_AUTOFILL_ADDRESSES_PREF, + ENABLED_AUTOFILL_CREDITCARDS_PREF, +} = FormAutofill; + +const { + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, +} = FormAutofillUtils; + +let gMessageObservers = new Set(); + +let FormAutofillStatus = { + _initialized: false, + + /** + * Cache of the Form Autofill status (considering preferences and storage). + */ + _active: null, + + /** + * Initializes observers and registers the message handler. + */ + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.obs.addObserver(this, "privacy-pane-loaded"); + + // Observing the pref and storage changes + Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.obs.addObserver(this, "formautofill-storage-changed"); + + // Only listen to credit card related preference if it is available + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this.injectElements(win.document); + } + Services.wm.addListener(this); + + Services.telemetry.setEventRecordingEnabled("creditcard", true); + }, + + /** + * Uninitializes FormAutofillStatus. This is for testing only. + * + * @private + */ + uninit() { + gFormAutofillStorage._saveImmediately(); + + if (!this._initialized) { + return; + } + this._initialized = false; + + this._active = null; + + Services.obs.removeObserver(this, "privacy-pane-loaded"); + Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.wm.removeListener(this); + + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + }, + + get formAutofillStorage() { + return gFormAutofillStorage; + }, + + /** + * Broadcast the status to frames when the form autofill status changes. + */ + onStatusChanged() { + log.debug("onStatusChanged: Status changed to", this._active); + Services.ppmm.sharedData.set("FormAutofill:enabled", this._active); + // Sync autofill enabled to make sure the value is up-to-date + // no matter when the new content process is initialized. + Services.ppmm.sharedData.flush(); + }, + + /** + * Query preference and storage status to determine the overall status of the + * form autofill feature. + * + * @returns {boolean} whether form autofill is active (enabled and has data) + */ + computeStatus() { + const savedFieldNames = Services.ppmm.sharedData.get( + "FormAutofill:savedFieldNames" + ); + + return ( + (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) || + Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) && + savedFieldNames && + savedFieldNames.size > 0 + ); + }, + + /** + * Update the status and trigger onStatusChanged, if necessary. + */ + updateStatus() { + log.debug("updateStatus"); + let wasActive = this._active; + this._active = this.computeStatus(); + if (this._active !== wasActive) { + this.onStatusChanged(); + } + }, + + updateSavedFieldNames() { + log.debug("updateSavedFieldNames"); + + let savedFieldNames; + // Don't access the credit cards store unless it is enabled. + if (FormAutofill.isAutofillCreditCardsAvailable) { + savedFieldNames = new Set([ + ...gFormAutofillStorage.addresses.getSavedFieldNames(), + ...gFormAutofillStorage.creditCards.getSavedFieldNames(), + ]); + } else { + savedFieldNames = gFormAutofillStorage.addresses.getSavedFieldNames(); + } + + Services.ppmm.sharedData.set( + "FormAutofill:savedFieldNames", + savedFieldNames + ); + Services.ppmm.sharedData.flush(); + + this.updateStatus(); + }, + + injectElements(doc) { + Services.scriptloader.loadSubScript( + "chrome://formautofill/content/customElements.js", + doc.ownerGlobal + ); + }, + + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") == + "navigator:browser" + ) { + this.injectElements(win.document); + } + }, + { once: true } + ); + }, + + onCloseWindow() {}, + + observe(subject, topic, data) { + log.debug("observe:", topic, "with data:", data); + switch (topic) { + case "privacy-pane-loaded": { + let formAutofillPreferences = new FormAutofillPreferences(); + let document = subject.document; + let prefFragment = formAutofillPreferences.init(document); + let formAutofillGroupBox = document.getElementById( + "formAutofillGroupBox" + ); + formAutofillGroupBox.appendChild(prefFragment); + break; + } + + case "nsPref:changed": { + // Observe pref changes and update _active cache if status is changed. + this.updateStatus(); + break; + } + + case "formautofill-storage-changed": { + // Early exit if only metadata is changed + if (data == "notifyUsed") { + break; + } + + this.updateSavedFieldNames(); + break; + } + + default: { + throw new Error( + `FormAutofillStatus: Unexpected topic observed: ${topic}` + ); + } + } + }, +}; + +// Lazily load the storage JSM to avoid disk I/O until absolutely needed. +// Once storage is loaded we need to update saved field names and inform content processes. +XPCOMUtils.defineLazyGetter(this, "gFormAutofillStorage", () => { + let { formAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm" + ); + log.debug("Loading formAutofillStorage"); + + formAutofillStorage.initialize().then(() => { + // Update the saved field names to compute the status and update child processes. + FormAutofillStatus.updateSavedFieldNames(); + }); + + return formAutofillStorage; +}); + +class FormAutofillParent extends JSWindowActorParent { + constructor() { + super(); + FormAutofillStatus.init(); + } + + static addMessageObserver(observer) { + gMessageObservers.add(observer); + } + + static removeMessageObserver(observer) { + gMessageObservers.delete(observer); + } + + /** + * Handles the message coming from FormAutofillContent. + * + * @param {string} message.name The name of the message. + * @param {object} message.data The data of the message. + */ + async receiveMessage({ name, data }) { + switch (name) { + case "FormAutofill:InitStorage": { + await gFormAutofillStorage.initialize(); + break; + } + case "FormAutofill:GetRecords": { + return FormAutofillParent._getRecords(data); + } + case "FormAutofill:OnFormSubmit": { + this.notifyMessageObservers("onFormSubmitted", data); + await this._onFormSubmit(data); + break; + } + case "FormAutofill:OpenPreferences": { + const win = BrowserWindowTracker.getTopWindow(); + win.openPreferences("privacy-form-autofill"); + break; + } + case "FormAutofill:GetDecryptedString": { + let { cipherText, reauth } = data; + if (!FormAutofillUtils._reauthEnabledByUser) { + log.debug("Reauth is disabled"); + reauth = false; + } + let string; + try { + string = await OSKeyStore.decrypt(cipherText, reauth); + } catch (e) { + if (e.result != Cr.NS_ERROR_ABORT) { + throw e; + } + log.warn("User canceled encryption login"); + } + return string; + } + case "FormAutofill:UpdateWarningMessage": + this.notifyMessageObservers("updateWarningNote", data); + break; + + case "FormAutofill:FieldsIdentified": + this.notifyMessageObservers("fieldsIdentified", data); + break; + + // The remaining Save and Remove messages are invoked only by tests. + case "FormAutofill:SaveAddress": { + if (data.guid) { + await gFormAutofillStorage.addresses.update(data.guid, data.address); + } else { + await gFormAutofillStorage.addresses.add(data.address); + } + break; + } + case "FormAutofill:SaveCreditCard": { + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + log.warn("User canceled encryption login"); + return undefined; + } + await gFormAutofillStorage.creditCards.add(data.creditcard); + break; + } + case "FormAutofill:RemoveAddresses": { + data.guids.forEach(guid => gFormAutofillStorage.addresses.remove(guid)); + break; + } + case "FormAutofill:RemoveCreditCards": { + data.guids.forEach(guid => + gFormAutofillStorage.creditCards.remove(guid) + ); + break; + } + } + + return undefined; + } + + notifyMessageObservers(callbackName, data) { + for (let observer of gMessageObservers) { + try { + if (callbackName in observer) { + observer[callbackName]( + data, + this.manager.browsingContext.topChromeWindow + ); + } + } catch (ex) { + Cu.reportError(ex); + } + } + } + + /** + * Get the records from profile store and return results back to content + * process. It will decrypt the credit card number and append + * "cc-number-decrypted" to each record if OSKeyStore isn't set. + * + * This is static as a unit test calls this. + * + * @private + * @param {string} data.collectionName + * The name used to specify which collection to retrieve records. + * @param {string} data.searchString + * The typed string for filtering out the matched records. + * @param {string} data.info + * The input autocomplete property's information. + */ + static async _getRecords({ collectionName, searchString, info }) { + let collection = gFormAutofillStorage[collectionName]; + if (!collection) { + return []; + } + + let recordsInCollection = await collection.getAll(); + if (!info || !info.fieldName || !recordsInCollection.length) { + return recordsInCollection; + } + + let isCC = collectionName == CREDITCARDS_COLLECTION_NAME; + // We don't filter "cc-number" + if (isCC && info.fieldName == "cc-number") { + recordsInCollection = recordsInCollection.filter( + record => !!record["cc-number"] + ); + return recordsInCollection; + } + + let records = []; + let lcSearchString = searchString.toLowerCase(); + + for (let record of recordsInCollection) { + let fieldValue = record[info.fieldName]; + if (!fieldValue) { + continue; + } + + if ( + collectionName == ADDRESSES_COLLECTION_NAME && + record.country && + !FormAutofill.supportedCountries.includes(record.country) + ) { + // Address autofill isn't supported for the record's country so we don't + // want to attempt to potentially incorrectly fill the address fields. + continue; + } + + if ( + lcSearchString && + !String(fieldValue) + .toLowerCase() + .startsWith(lcSearchString) + ) { + continue; + } + records.push(record); + } + + return records; + } + + async _onAddressSubmit(address, browser, timeStartedFillingMS) { + let showDoorhanger = null; + if (!FormAutofill.isAutofillAddressesCaptureEnabled) { + return showDoorhanger; + } + if (address.guid) { + // Avoid updating the fields that users don't modify. + let originalAddress = await gFormAutofillStorage.addresses.get( + address.guid + ); + for (let field in address.record) { + if (address.untouchedFields.includes(field) && originalAddress[field]) { + address.record[field] = originalAddress[field]; + } + } + + if ( + !(await gFormAutofillStorage.addresses.mergeIfPossible( + address.guid, + address.record, + true + )) + ) { + this._recordFormFillingTime( + "address", + "autofill-update", + timeStartedFillingMS + ); + + showDoorhanger = async () => { + const description = FormAutofillUtils.getAddressLabel(address.record); + const state = await FormAutofillDoorhanger.show( + browser, + "updateAddress", + description + ); + let changedGUIDs = await gFormAutofillStorage.addresses.mergeToStorage( + address.record, + true + ); + switch (state) { + case "create": + if (!changedGUIDs.length) { + changedGUIDs.push( + await gFormAutofillStorage.addresses.add(address.record) + ); + } + break; + case "update": + if (!changedGUIDs.length) { + await gFormAutofillStorage.addresses.update( + address.guid, + address.record, + true + ); + changedGUIDs.push(address.guid); + } else { + gFormAutofillStorage.addresses.remove(address.guid); + } + break; + } + changedGUIDs.forEach(guid => + gFormAutofillStorage.addresses.notifyUsed(guid) + ); + }; + // Address should be updated + Services.telemetry.scalarAdd( + "formautofill.addresses.fill_type_autofill_update", + 1 + ); + } else { + this._recordFormFillingTime( + "address", + "autofill", + timeStartedFillingMS + ); + gFormAutofillStorage.addresses.notifyUsed(address.guid); + // Address is merged successfully + Services.telemetry.scalarAdd( + "formautofill.addresses.fill_type_autofill", + 1 + ); + } + } else { + let changedGUIDs = await gFormAutofillStorage.addresses.mergeToStorage( + address.record + ); + if (!changedGUIDs.length) { + changedGUIDs.push( + await gFormAutofillStorage.addresses.add(address.record) + ); + } + changedGUIDs.forEach(guid => + gFormAutofillStorage.addresses.notifyUsed(guid) + ); + this._recordFormFillingTime("address", "manual", timeStartedFillingMS); + + // Show first time use doorhanger + if (FormAutofill.isAutofillAddressesFirstTimeUse) { + Services.prefs.setBoolPref( + FormAutofill.ADDRESSES_FIRST_TIME_USE_PREF, + false + ); + showDoorhanger = async () => { + const description = FormAutofillUtils.getAddressLabel(address.record); + const state = await FormAutofillDoorhanger.show( + browser, + "firstTimeUse", + description + ); + if (state !== "open-pref") { + return; + } + + browser.ownerGlobal.openPreferences("privacy-address-autofill"); + }; + } else { + // We want to exclude the first time form filling. + Services.telemetry.scalarAdd( + "formautofill.addresses.fill_type_manual", + 1 + ); + } + } + return showDoorhanger; + } + + async _onCreditCardSubmit(creditCard, browser, timeStartedFillingMS) { + if (FormAutofill.isAutofillCreditCardsHideUI) { + return false; + } + + // Updates the used status for shield/heartbeat to recognize users who have + // used Credit Card Autofill. + let setUsedStatus = status => { + if (FormAutofill.AutofillCreditCardsUsedStatus < status) { + Services.prefs.setIntPref( + FormAutofill.CREDITCARDS_USED_STATUS_PREF, + status + ); + } + }; + + // Remove invalid cc-type values + if ( + creditCard.record["cc-type"] && + !CreditCard.isValidNetwork(creditCard.record["cc-type"]) + ) { + // Let's reset the credit card to empty, and then network auto-detect will + // pick it up. + creditCard.record["cc-type"] = ""; + } + + // If `guid` is present, the form has been autofilled. + if (creditCard.guid) { + // Indicate that the user has used Credit Card Autofill to fill in a form. + setUsedStatus(3); + + let originalCCData = await gFormAutofillStorage.creditCards.get( + creditCard.guid + ); + let recordUnchanged = true; + for (let field in creditCard.record) { + if (creditCard.record[field] === "" && !originalCCData[field]) { + continue; + } + // Avoid updating the fields that users don't modify, but skip number field + // because we don't want to trigger decryption here. + let untouched = creditCard.untouchedFields.includes(field); + if (untouched && field !== "cc-number") { + creditCard.record[field] = originalCCData[field]; + } + // recordUnchanged will be false if one of the field is changed. + recordUnchanged &= untouched; + } + + if (recordUnchanged) { + gFormAutofillStorage.creditCards.notifyUsed(creditCard.guid); + // Add probe to record credit card autofill(without modification). + Services.telemetry.scalarAdd( + "formautofill.creditCards.fill_type_autofill", + 1 + ); + this._recordFormFillingTime( + "creditCard", + "autofill", + timeStartedFillingMS + ); + return false; + } + // Add the probe to record credit card autofill with modification. + Services.telemetry.scalarAdd( + "formautofill.creditCards.fill_type_autofill_modified", + 1 + ); + this._recordFormFillingTime( + "creditCard", + "autofill-update", + timeStartedFillingMS + ); + } else { + // Add the probe to record credit card manual filling. + Services.telemetry.scalarAdd( + "formautofill.creditCards.fill_type_manual", + 1 + ); + this._recordFormFillingTime("creditCard", "manual", timeStartedFillingMS); + + let existingGuid = await gFormAutofillStorage.creditCards.getDuplicateGuid( + creditCard.record + ); + + if (existingGuid) { + creditCard.guid = existingGuid; + + let originalCCData = await gFormAutofillStorage.creditCards.get( + creditCard.guid + ); + + gFormAutofillStorage.creditCards._normalizeRecord(creditCard.record); + + // If the credit card record is a duplicate, check if the fields match the + // record. + let recordUnchanged = true; + for (let field in creditCard.record) { + if (field == "cc-number") { + continue; + } + if (creditCard.record[field] != originalCCData[field]) { + recordUnchanged = false; + break; + } + } + + if (recordUnchanged) { + // Indicate that the user neither sees the doorhanger nor uses Autofill + // but somehow has a duplicate record in the storage. Will be reset to 2 + // if the doorhanger actually shows below. + setUsedStatus(1); + gFormAutofillStorage.creditCards.notifyUsed(creditCard.guid); + return false; + } + } + } + + // Indicate that the user has seen the doorhanger. + setUsedStatus(2); + + return async () => { + // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger. + if (!FormAutofill.isAutofillCreditCardsEnabled) { + return; + } + + let number = + creditCard.record["cc-number"] || + creditCard.record["cc-number-decrypted"]; + let name = creditCard.record["cc-name"]; + const description = await CreditCard.getLabel({ name, number }); + + const telemetryObject = creditCard.guid + ? "update_doorhanger" + : "capture_doorhanger"; + Services.telemetry.recordEvent( + "creditcard", + "show", + telemetryObject, + creditCard.flowId + ); + + const state = await FormAutofillDoorhanger.show( + browser, + creditCard.guid ? "updateCreditCard" : "addCreditCard", + description + ); + if (state == "cancel") { + Services.telemetry.recordEvent( + "creditcard", + "cancel", + telemetryObject, + creditCard.flowId + ); + return; + } + + if (state == "disable") { + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Services.telemetry.recordEvent( + "creditcard", + "disable", + telemetryObject, + creditCard.flowId + ); + return; + } + + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + log.warn("User canceled encryption login"); + return; + } + + let changedGUIDs = []; + if (creditCard.guid) { + if (state == "update") { + Services.telemetry.recordEvent( + "creditcard", + "update", + telemetryObject, + creditCard.flowId + ); + await gFormAutofillStorage.creditCards.update( + creditCard.guid, + creditCard.record, + true + ); + changedGUIDs.push(creditCard.guid); + } else if ("create") { + Services.telemetry.recordEvent( + "creditcard", + "save", + telemetryObject, + creditCard.flowId + ); + changedGUIDs.push( + await gFormAutofillStorage.creditCards.add(creditCard.record) + ); + } + } else { + changedGUIDs.push( + ...(await gFormAutofillStorage.creditCards.mergeToStorage( + creditCard.record + )) + ); + if (!changedGUIDs.length) { + Services.telemetry.recordEvent( + "creditcard", + "save", + telemetryObject, + creditCard.flowId + ); + changedGUIDs.push( + await gFormAutofillStorage.creditCards.add(creditCard.record) + ); + } + } + changedGUIDs.forEach(guid => + gFormAutofillStorage.creditCards.notifyUsed(guid) + ); + }; + } + + async _onFormSubmit(data) { + let { + profile: { address, creditCard }, + timeStartedFillingMS, + } = data; + + // Don't record filling time if any type of records has more than one section being + // populated. We've been recording the filling time, so the other cases that aren't + // recorded on the same basis should be out of the data samples. E.g. Filling time of + // populating one profile is different from populating two sections, therefore, we + // shouldn't record the later to regress the representation of existing statistics. + if (address.length > 1 || creditCard.length > 1) { + timeStartedFillingMS = null; + } + + let browser = this.manager.browsingContext.top.embedderElement; + + // Transmit the telemetry immediately in the meantime form submitted, and handle these pending + // doorhangers at a later. + await Promise.all( + [ + await Promise.all( + address.map(addrRecord => + this._onAddressSubmit(addrRecord, browser, timeStartedFillingMS) + ) + ), + await Promise.all( + creditCard.map(ccRecord => + this._onCreditCardSubmit(ccRecord, browser, timeStartedFillingMS) + ) + ), + ] + .map(pendingDoorhangers => { + return pendingDoorhangers.filter( + pendingDoorhanger => + !!pendingDoorhanger && typeof pendingDoorhanger == "function" + ); + }) + .map(pendingDoorhangers => + (async () => { + for (const showDoorhanger of pendingDoorhangers) { + await showDoorhanger(); + } + })() + ) + ); + } + + /** + * Set the probes for the filling time with specific filling type and form type. + * + * @private + * @param {string} formType + * 3 type of form (address/creditcard/address-creditcard). + * @param {string} fillingType + * 3 filling type (manual/autofill/autofill-update). + * @param {int|null} startedFillingMS + * Time that form started to filling in ms. Early return if start time is null. + */ + _recordFormFillingTime(formType, fillingType, startedFillingMS) { + if (!startedFillingMS) { + return; + } + let histogram = Services.telemetry.getKeyedHistogramById( + "FORM_FILLING_REQUIRED_TIME_MS" + ); + histogram.add(`${formType}-${fillingType}`, Date.now() - startedFillingMS); + } +} |