diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /toolkit/components/passwordmgr/LoginManagerContent.jsm | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'toolkit/components/passwordmgr/LoginManagerContent.jsm')
-rw-r--r-- | toolkit/components/passwordmgr/LoginManagerContent.jsm | 1619 |
1 files changed, 1619 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginManagerContent.jsm b/toolkit/components/passwordmgr/LoginManagerContent.jsm new file mode 100644 index 000000000..60805530d --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm @@ -0,0 +1,1619 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ "LoginManagerContent", + "LoginFormFactory", + "UserAutoCompleteResult" ]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; +const PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS = 1; +const AUTOCOMPLETE_AFTER_CONTEXTMENU_THRESHOLD_MS = 250; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); +Cu.import("resource://gre/modules/InsecurePasswordUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FormLikeFactory", + "resource://gre/modules/FormLikeFactory.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginRecipesContent", + "resource://gre/modules/LoginRecipes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "InsecurePasswordUtils", + "resource://gre/modules/InsecurePasswordUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gNetUtil", + "@mozilla.org/network/util;1", + "nsINetUtil"); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("LoginManagerContent"); + return logger.log.bind(logger); +}); + +// These mirror signon.* prefs. +var gEnabled, gAutofillForms, gStoreWhenAutocompleteOff; +var gLastContextMenuEventTimeStamp = Number.NEGATIVE_INFINITY; + +var observer = { + QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsIFormSubmitObserver, + Ci.nsIWebProgressListener, + Ci.nsIDOMEventListener, + Ci.nsISupportsWeakReference]), + + // nsIFormSubmitObserver + notify(formElement, aWindow, actionURI) { + log("observer notified for form submission."); + + // We're invoked before the content's |onsubmit| handlers, so we + // can grab form data before it might be modified (see bug 257781). + + try { + let formLike = LoginFormFactory.createFromForm(formElement); + LoginManagerContent._onFormSubmit(formLike); + } catch (e) { + log("Caught error in onFormSubmit(", e.lineNumber, "):", e.message); + Cu.reportError(e); + } + + return true; // Always return true, or form submit will be canceled. + }, + + onPrefChange() { + gEnabled = Services.prefs.getBoolPref("signon.rememberSignons"); + gAutofillForms = Services.prefs.getBoolPref("signon.autofillForms"); + gStoreWhenAutocompleteOff = Services.prefs.getBoolPref("signon.storeWhenAutocompleteOff"); + }, + + // nsIWebProgressListener + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + // Only handle pushState/replaceState here. + if (!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) || + !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)) { + return; + } + + log("onLocationChange handled:", aLocation.spec, aWebProgress.DOMWindow.document); + + LoginManagerContent._onNavigation(aWebProgress.DOMWindow.document); + }, + + onStateChange(aWebProgress, aRequest, aState, aStatus) { + if (!(aState & Ci.nsIWebProgressListener.STATE_START)) { + return; + } + + // We only care about when a page triggered a load, not the user. For example: + // clicking refresh/back/forward, typing a URL and hitting enter, and loading a bookmark aren't + // likely to be when a user wants to save a login. + let channel = aRequest.QueryInterface(Ci.nsIChannel); + let triggeringPrincipal = channel.loadInfo.triggeringPrincipal; + if (triggeringPrincipal.isNullPrincipal || + triggeringPrincipal.equals(Services.scriptSecurityManager.getSystemPrincipal())) { + return; + } + + // Don't handle history navigation, reload, or pushState not triggered via chrome UI. + // e.g. history.go(-1), location.reload(), history.replaceState() + if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) { + log("onStateChange: loadType isn't LOAD_CMD_NORMAL:", aWebProgress.loadType); + return; + } + + log("onStateChange handled:", channel); + LoginManagerContent._onNavigation(aWebProgress.DOMWindow.document); + }, + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + if (!gEnabled) { + return; + } + + switch (aEvent.type) { + // Only used for username fields. + case "focus": { + LoginManagerContent._onUsernameFocus(aEvent); + break; + } + + case "contextmenu": { + gLastContextMenuEventTimeStamp = Date.now(); + break; + } + + default: { + throw new Error("Unexpected event"); + } + } + }, +}; + +Services.obs.addObserver(observer, "earlyformsubmit", false); +var prefBranch = Services.prefs.getBranch("signon."); +prefBranch.addObserver("", observer.onPrefChange, false); + +observer.onPrefChange(); // read initial values + + +function messageManagerFromWindow(win) { + return win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); +} + +// This object maps to the "child" process (even in the single-process case). +var LoginManagerContent = { + + __formFillService : null, // FormFillController, for username autocompleting + get _formFillService() { + if (!this.__formFillService) + this.__formFillService = + Cc["@mozilla.org/satchel/form-fill-controller;1"]. + getService(Ci.nsIFormFillController); + return this.__formFillService; + }, + + _getRandomId() { + return Cc["@mozilla.org/uuid-generator;1"] + .getService(Ci.nsIUUIDGenerator).generateUUID().toString(); + }, + + _messages: [ "RemoteLogins:loginsFound", + "RemoteLogins:loginsAutoCompleted" ], + + /** + * WeakMap of the root element of a FormLike to the FormLike representing its fields. + * + * This is used to be able to lookup an existing FormLike for a given root element since multiple + * calls to LoginFormFactory won't give the exact same object. When batching fills we don't always + * want to use the most recent list of elements for a FormLike since we may end up doing multiple + * fills for the same set of elements when a field gets added between arming and running the + * DeferredTask. + * + * @type {WeakMap} + */ + _formLikeByRootElement: new WeakMap(), + + /** + * WeakMap of the root element of a WeakMap to the DeferredTask to fill its fields. + * + * This is used to be able to throttle fills for a FormLike since onDOMInputPasswordAdded gets + * dispatched for each password field added to a document but we only want to fill once per + * FormLike when multiple fields are added at once. + * + * @type {WeakMap} + */ + _deferredPasswordAddedTasksByRootElement: new WeakMap(), + + // Map from form login requests to information about that request. + _requests: new Map(), + + // Number of outstanding requests to each manager. + _managers: new Map(), + + _takeRequest(msg) { + let data = msg.data; + let request = this._requests.get(data.requestId); + + this._requests.delete(data.requestId); + + let count = this._managers.get(msg.target); + if (--count === 0) { + this._managers.delete(msg.target); + + for (let message of this._messages) + msg.target.removeMessageListener(message, this); + } else { + this._managers.set(msg.target, count); + } + + return request; + }, + + _sendRequest(messageManager, requestData, + name, messageData) { + let count; + if (!(count = this._managers.get(messageManager))) { + this._managers.set(messageManager, 1); + + for (let message of this._messages) + messageManager.addMessageListener(message, this); + } else { + this._managers.set(messageManager, ++count); + } + + let requestId = this._getRandomId(); + messageData.requestId = requestId; + + messageManager.sendAsyncMessage(name, messageData); + + let deferred = Promise.defer(); + requestData.promise = deferred; + this._requests.set(requestId, requestData); + return deferred.promise; + }, + + receiveMessage(msg, window) { + if (msg.name == "RemoteLogins:fillForm") { + this.fillForm({ + topDocument: window.document, + loginFormOrigin: msg.data.loginFormOrigin, + loginsFound: LoginHelper.vanillaObjectsToLogins(msg.data.logins), + recipes: msg.data.recipes, + inputElement: msg.objects.inputElement, + }); + return; + } + + let request = this._takeRequest(msg); + switch (msg.name) { + case "RemoteLogins:loginsFound": { + let loginsFound = LoginHelper.vanillaObjectsToLogins(msg.data.logins); + request.promise.resolve({ + form: request.form, + loginsFound: loginsFound, + recipes: msg.data.recipes, + }); + break; + } + + case "RemoteLogins:loginsAutoCompleted": { + let loginsFound = + LoginHelper.vanillaObjectsToLogins(msg.data.logins); + // If we're in the parent process, don't pass a message manager so our + // autocomplete result objects know they can remove the login from the + // login manager directly. + let messageManager = + (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) ? + msg.target : undefined; + request.promise.resolve({ logins: loginsFound, messageManager }); + break; + } + } + }, + + /** + * Get relevant logins and recipes from the parent + * + * @param {HTMLFormElement} form - form to get login data for + * @param {Object} options + * @param {boolean} options.showMasterPassword - whether to show a master password prompt + */ + _getLoginDataFromParent(form, options) { + let doc = form.ownerDocument; + let win = doc.defaultView; + + let formOrigin = LoginUtils._getPasswordOrigin(doc.documentURI); + if (!formOrigin) { + return Promise.reject("_getLoginDataFromParent: A form origin is required"); + } + let actionOrigin = LoginUtils._getActionOrigin(form); + + let messageManager = messageManagerFromWindow(win); + + // XXX Weak?? + let requestData = { form: form }; + let messageData = { formOrigin: formOrigin, + actionOrigin: actionOrigin, + options: options }; + + return this._sendRequest(messageManager, requestData, + "RemoteLogins:findLogins", + messageData); + }, + + _autoCompleteSearchAsync(aSearchString, aPreviousResult, + aElement, aRect) { + let doc = aElement.ownerDocument; + let form = LoginFormFactory.createFromField(aElement); + let win = doc.defaultView; + + let formOrigin = LoginUtils._getPasswordOrigin(doc.documentURI); + let actionOrigin = LoginUtils._getActionOrigin(form); + + let messageManager = messageManagerFromWindow(win); + + let remote = (Services.appinfo.processType === + Services.appinfo.PROCESS_TYPE_CONTENT); + + let previousResult = aPreviousResult ? + { searchString: aPreviousResult.searchString, + logins: LoginHelper.loginsToVanillaObjects(aPreviousResult.logins) } : + null; + + let requestData = {}; + let messageData = { formOrigin: formOrigin, + actionOrigin: actionOrigin, + searchString: aSearchString, + previousResult: previousResult, + rect: aRect, + isSecure: InsecurePasswordUtils.isFormSecure(form), + isPasswordField: aElement.type == "password", + remote: remote }; + + return this._sendRequest(messageManager, requestData, + "RemoteLogins:autoCompleteLogins", + messageData); + }, + + setupProgressListener(window) { + if (!LoginHelper.formlessCaptureEnabled) { + return; + } + + try { + let webProgress = window.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebNavigation). + QueryInterface(Ci.nsIDocShell). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(observer, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_LOCATION); + } catch (ex) { + // Ignore NS_ERROR_FAILURE if the progress listener was already added + } + }, + + onDOMFormHasPassword(event, window) { + if (!event.isTrusted) { + return; + } + + let form = event.target; + let formLike = LoginFormFactory.createFromForm(form); + log("onDOMFormHasPassword:", form, formLike); + this._fetchLoginsFromParentAndFillForm(formLike, window); + }, + + onDOMInputPasswordAdded(event, window) { + if (!event.isTrusted) { + return; + } + + let pwField = event.target; + if (pwField.form) { + // Fill is handled by onDOMFormHasPassword which is already throttled. + return; + } + + // Only setup the listener for formless inputs. + // Capture within a <form> but without a submit event is bug 1287202. + this.setupProgressListener(window); + + let formLike = LoginFormFactory.createFromField(pwField); + log("onDOMInputPasswordAdded:", pwField, formLike); + + let deferredTask = this._deferredPasswordAddedTasksByRootElement.get(formLike.rootElement); + if (!deferredTask) { + log("Creating a DeferredTask to call _fetchLoginsFromParentAndFillForm soon"); + this._formLikeByRootElement.set(formLike.rootElement, formLike); + + deferredTask = new DeferredTask(function* deferredInputProcessing() { + // Get the updated formLike instead of the one at the time of creating the DeferredTask via + // a closure since it could be stale since FormLike.elements isn't live. + let formLike2 = this._formLikeByRootElement.get(formLike.rootElement); + log("Running deferred processing of onDOMInputPasswordAdded", formLike2); + this._deferredPasswordAddedTasksByRootElement.delete(formLike2.rootElement); + this._fetchLoginsFromParentAndFillForm(formLike2, window); + }.bind(this), PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS); + + this._deferredPasswordAddedTasksByRootElement.set(formLike.rootElement, deferredTask); + } + + if (deferredTask.isArmed) { + log("DeferredTask is already armed so just updating the FormLike"); + // We update the FormLike so it (most important .elements) is fresh when the task eventually + // runs since changes to the elements could affect our field heuristics. + this._formLikeByRootElement.set(formLike.rootElement, formLike); + } else if (window.document.readyState == "complete") { + log("Arming the DeferredTask we just created since document.readyState == 'complete'"); + deferredTask.arm(); + } else { + window.addEventListener("DOMContentLoaded", function armPasswordAddedTask() { + window.removeEventListener("DOMContentLoaded", armPasswordAddedTask); + log("Arming the onDOMInputPasswordAdded DeferredTask due to DOMContentLoaded"); + deferredTask.arm(); + }); + } + }, + + /** + * Fetch logins from the parent for a given form and then attempt to fill it. + * + * @param {FormLike} form to fetch the logins for then try autofill. + * @param {Window} window + */ + _fetchLoginsFromParentAndFillForm(form, window) { + this._detectInsecureFormLikes(window); + + let messageManager = messageManagerFromWindow(window); + messageManager.sendAsyncMessage("LoginStats:LoginEncountered"); + + if (!gEnabled) { + return; + } + + this._getLoginDataFromParent(form, { showMasterPassword: true }) + .then(this.loginsFound.bind(this)) + .then(null, Cu.reportError); + }, + + onPageShow(event, window) { + this._detectInsecureFormLikes(window); + }, + + /** + * Maps all DOM content documents in this content process, including those in + * frames, to the current state used by the Login Manager. + */ + loginFormStateByDocument: new WeakMap(), + + /** + * Retrieves a reference to the state object associated with the given + * document. This is initialized to an object with default values. + */ + stateForDocument(document) { + let loginFormState = this.loginFormStateByDocument.get(document); + if (!loginFormState) { + loginFormState = { + /** + * Keeps track of filled fields and values. + */ + fillsByRootElement: new WeakMap(), + loginFormRootElements: new Set(), + }; + this.loginFormStateByDocument.set(document, loginFormState); + } + return loginFormState; + }, + + /** + * Compute whether there is an insecure login form on any frame of the current page, and + * notify the parent process. This is used to control whether insecure password UI appears. + */ + _detectInsecureFormLikes(topWindow) { + log("_detectInsecureFormLikes", topWindow.location.href); + + // Returns true if this window or any subframes have insecure login forms. + let hasInsecureLoginForms = (thisWindow) => { + let doc = thisWindow.document; + let hasLoginForm = this.stateForDocument(doc).loginFormRootElements.size > 0; + // Ignores window.opener, because it's not relevant for indicating + // form security. See InsecurePasswordUtils docs for details. + return (hasLoginForm && !thisWindow.isSecureContextIfOpenerIgnored) || + Array.some(thisWindow.frames, + frame => hasInsecureLoginForms(frame)); + }; + + let messageManager = messageManagerFromWindow(topWindow); + messageManager.sendAsyncMessage("RemoteLogins:insecureLoginFormPresent", { + hasInsecureLoginForms: hasInsecureLoginForms(topWindow), + }); + }, + + /** + * Perform a password fill upon user request coming from the parent process. + * The fill will be in the form previously identified during page navigation. + * + * @param An object with the following properties: + * { + * topDocument: + * DOM document currently associated to the the top-level window + * for which the fill is requested. This may be different from the + * document that originally caused the login UI to be displayed. + * loginFormOrigin: + * String with the origin for which the login UI was displayed. + * This must match the origin of the form used for the fill. + * loginsFound: + * Array containing the login to fill. While other messages may + * have more logins, for this use case this is expected to have + * exactly one element. The origin of the login may be different + * from the origin of the form used for the fill. + * recipes: + * Fill recipes transmitted together with the original message. + * inputElement: + * Username or password input element from the form we want to fill. + * } + */ + fillForm({ topDocument, loginFormOrigin, loginsFound, recipes, inputElement }) { + if (!inputElement) { + log("fillForm: No input element specified"); + return; + } + if (LoginUtils._getPasswordOrigin(topDocument.documentURI) != loginFormOrigin) { + if (!inputElement || + LoginUtils._getPasswordOrigin(inputElement.ownerDocument.documentURI) != loginFormOrigin) { + log("fillForm: The requested origin doesn't match the one form the", + "document. This may mean we navigated to a document from a different", + "site before we had a chance to indicate this change in the user", + "interface."); + return; + } + } + + let clobberUsername = true; + let options = { + inputElement, + }; + + let form = LoginFormFactory.createFromField(inputElement); + if (inputElement.type == "password") { + clobberUsername = false; + } + this._fillForm(form, true, clobberUsername, true, true, loginsFound, recipes, options); + }, + + loginsFound({ form, loginsFound, recipes }) { + let doc = form.ownerDocument; + let autofillForm = gAutofillForms && !PrivateBrowsingUtils.isContentWindowPrivate(doc.defaultView); + + this._fillForm(form, autofillForm, false, false, false, loginsFound, recipes); + }, + + /** + * Focus event handler for username fields to decide whether to show autocomplete. + * @param {FocusEvent} event + */ + _onUsernameFocus(event) { + let focusedField = event.target; + if (!focusedField.mozIsTextField(true) || focusedField.readOnly) { + return; + } + + if (this._isLoginAlreadyFilled(focusedField)) { + log("_onUsernameFocus: Already filled"); + return; + } + + /* + * A `focus` event is fired before a `contextmenu` event if a user right-clicks into an + * unfocused field. In that case we don't want to show both autocomplete and a context menu + * overlapping so we spin the event loop to see if a `contextmenu` event is coming next. If no + * `contextmenu` event was seen and the focused field is still focused by the form fill + * controller then show the autocomplete popup. + */ + let timestamp = Date.now(); + setTimeout(function maybeOpenAutocompleteAfterFocus() { + // Even though the `focus` event happens first, its .timeStamp is greater in + // testing and I don't want to rely on that so the absolute value is used. + let timeDiff = Math.abs(gLastContextMenuEventTimeStamp - timestamp); + if (timeDiff < AUTOCOMPLETE_AFTER_CONTEXTMENU_THRESHOLD_MS) { + log("Not opening autocomplete after focus since a context menu was opened within", + timeDiff, "ms"); + return; + } + + if (this._formFillService.focusedInput == focusedField) { + log("maybeOpenAutocompleteAfterFocus: Opening the autocomplete popup. Time diff:", timeDiff); + this._formFillService.showPopup(); + } else { + log("maybeOpenAutocompleteAfterFocus: FormFillController has a different focused input"); + } + }.bind(this), 0); + }, + + /** + * Listens for DOMAutoComplete and blur events on an input field. + */ + onUsernameInput(event) { + if (!event.isTrusted) + return; + + if (!gEnabled) + return; + + var acInputField = event.target; + + // This is probably a bit over-conservatative. + if (!(acInputField.ownerDocument instanceof Ci.nsIDOMHTMLDocument)) + return; + + if (!LoginHelper.isUsernameFieldType(acInputField)) + return; + + var acForm = LoginFormFactory.createFromField(acInputField); + if (!acForm) + return; + + // If the username is blank, bail out now -- we don't want + // fillForm() to try filling in a login without a username + // to filter on (bug 471906). + if (!acInputField.value) + return; + + log("onUsernameInput from", event.type); + + let doc = acForm.ownerDocument; + let messageManager = messageManagerFromWindow(doc.defaultView); + let recipes = messageManager.sendSyncMessage("RemoteLogins:findRecipes", { + formOrigin: LoginUtils._getPasswordOrigin(doc.documentURI), + })[0]; + + // Make sure the username field fillForm will use is the + // same field as the autocomplete was activated on. + var [usernameField, passwordField, ignored] = + this._getFormFields(acForm, false, recipes); + if (usernameField == acInputField && passwordField) { + this._getLoginDataFromParent(acForm, { showMasterPassword: false }) + .then(({ form, loginsFound, recipes }) => { + this._fillForm(form, true, false, true, true, loginsFound, recipes); + }) + .then(null, Cu.reportError); + } else { + // Ignore the event, it's for some input we don't care about. + } + }, + + /** + * @param {FormLike} form - the FormLike to look for password fields in. + * @param {bool} [skipEmptyFields=false] - Whether to ignore password fields with no value. + * Used at capture time since saving empty values isn't + * useful. + * @return {Array|null} Array of password field elements for the specified form. + * If no pw fields are found, or if more than 3 are found, then null + * is returned. + */ + _getPasswordFields(form, skipEmptyFields = false) { + // Locate the password fields in the form. + let pwFields = []; + for (let i = 0; i < form.elements.length; i++) { + let element = form.elements[i]; + if (!(element instanceof Ci.nsIDOMHTMLInputElement) || + element.type != "password") { + continue; + } + + if (skipEmptyFields && !element.value.trim()) { + continue; + } + + pwFields[pwFields.length] = { + index : i, + element : element + }; + } + + // If too few or too many fields, bail out. + if (pwFields.length == 0) { + log("(form ignored -- no password fields.)"); + return null; + } else if (pwFields.length > 3) { + log("(form ignored -- too many password fields. [ got ", pwFields.length, "])"); + return null; + } + + return pwFields; + }, + + /** + * Returns the username and password fields found in the form. + * Can handle complex forms by trying to figure out what the + * relevant fields are. + * + * @param {FormLike} form + * @param {bool} isSubmission + * @param {Set} recipes + * @return {Array} [usernameField, newPasswordField, oldPasswordField] + * + * usernameField may be null. + * newPasswordField will always be non-null. + * oldPasswordField may be null. If null, newPasswordField is just + * "theLoginField". If not null, the form is apparently a + * change-password field, with oldPasswordField containing the password + * that is being changed. + * + * Note that even though we can create a FormLike from a text field, + * this method will only return a non-null usernameField if the + * FormLike has a password field. + */ + _getFormFields(form, isSubmission, recipes) { + var usernameField = null; + var pwFields = null; + var fieldOverrideRecipe = LoginRecipesContent.getFieldOverrides(recipes, form); + if (fieldOverrideRecipe) { + var pwOverrideField = LoginRecipesContent.queryLoginField( + form, + fieldOverrideRecipe.passwordSelector + ); + if (pwOverrideField) { + // The field from the password override may be in a different FormLike. + let formLike = LoginFormFactory.createFromField(pwOverrideField); + pwFields = [{ + index : [...formLike.elements].indexOf(pwOverrideField), + element : pwOverrideField, + }]; + } + + var usernameOverrideField = LoginRecipesContent.queryLoginField( + form, + fieldOverrideRecipe.usernameSelector + ); + if (usernameOverrideField) { + usernameField = usernameOverrideField; + } + } + + if (!pwFields) { + // Locate the password field(s) in the form. Up to 3 supported. + // If there's no password field, there's nothing for us to do. + pwFields = this._getPasswordFields(form, isSubmission); + } + + if (!pwFields) { + return [null, null, null]; + } + + if (!usernameField) { + // Locate the username field in the form by searching backwards + // from the first password field, assume the first text field is the + // username. We might not find a username field if the user is + // already logged in to the site. + for (var i = pwFields[0].index - 1; i >= 0; i--) { + var element = form.elements[i]; + if (!LoginHelper.isUsernameFieldType(element)) { + continue; + } + + if (fieldOverrideRecipe && fieldOverrideRecipe.notUsernameSelector && + element.matches(fieldOverrideRecipe.notUsernameSelector)) { + continue; + } + + usernameField = element; + break; + } + } + + if (!usernameField) + log("(form -- no username field found)"); + else + log("Username field ", usernameField, "has name/value:", + usernameField.name, "/", usernameField.value); + + // If we're not submitting a form (it's a page load), there are no + // password field values for us to use for identifying fields. So, + // just assume the first password field is the one to be filled in. + if (!isSubmission || pwFields.length == 1) { + var passwordField = pwFields[0].element; + log("Password field", passwordField, "has name: ", passwordField.name); + return [usernameField, passwordField, null]; + } + + + // Try to figure out WTF is in the form based on the password values. + var oldPasswordField, newPasswordField; + var pw1 = pwFields[0].element.value; + var pw2 = pwFields[1].element.value; + var pw3 = (pwFields[2] ? pwFields[2].element.value : null); + + if (pwFields.length == 3) { + // Look for two identical passwords, that's the new password + + if (pw1 == pw2 && pw2 == pw3) { + // All 3 passwords the same? Weird! Treat as if 1 pw field. + newPasswordField = pwFields[0].element; + oldPasswordField = null; + } else if (pw1 == pw2) { + newPasswordField = pwFields[0].element; + oldPasswordField = pwFields[2].element; + } else if (pw2 == pw3) { + oldPasswordField = pwFields[0].element; + newPasswordField = pwFields[2].element; + } else if (pw1 == pw3) { + // A bit odd, but could make sense with the right page layout. + newPasswordField = pwFields[0].element; + oldPasswordField = pwFields[1].element; + } else { + // We can't tell which of the 3 passwords should be saved. + log("(form ignored -- all 3 pw fields differ)"); + return [null, null, null]; + } + } else if (pw1 == pw2) { + // pwFields.length == 2 + // Treat as if 1 pw field + newPasswordField = pwFields[0].element; + oldPasswordField = null; + } else { + // Just assume that the 2nd password is the new password + oldPasswordField = pwFields[0].element; + newPasswordField = pwFields[1].element; + } + + log("Password field (new) id/name is: ", newPasswordField.id, " / ", newPasswordField.name); + if (oldPasswordField) { + log("Password field (old) id/name is: ", oldPasswordField.id, " / ", oldPasswordField.name); + } else { + log("Password field (old):", oldPasswordField); + } + return [usernameField, newPasswordField, oldPasswordField]; + }, + + + /** + * @return true if the page requests autocomplete be disabled for the + * specified element. + */ + _isAutocompleteDisabled(element) { + return element && element.autocomplete == "off"; + }, + + /** + * Trigger capture on any relevant FormLikes due to a navigation alone (not + * necessarily due to an actual form submission). This method is used to + * capture logins for cases where form submit events are not used. + * + * To avoid multiple notifications for the same FormLike, this currently + * avoids capturing when dealing with a real <form> which are ideally already + * using a submit event. + * + * @param {Document} document being navigated + */ + _onNavigation(aDocument) { + let state = this.stateForDocument(aDocument); + let loginFormRootElements = state.loginFormRootElements; + log("_onNavigation: state:", state, "loginFormRootElements size:", loginFormRootElements.size, + "document:", aDocument); + + for (let formRoot of state.loginFormRootElements) { + if (formRoot instanceof Ci.nsIDOMHTMLFormElement) { + // For now only perform capture upon navigation for FormLike's without + // a <form> to avoid capture from both an earlyformsubmit and + // navigation for the same "form". + log("Ignoring navigation for the form root to avoid multiple prompts " + + "since it was for a real <form>"); + continue; + } + let formLike = this._formLikeByRootElement.get(formRoot); + this._onFormSubmit(formLike); + } + }, + + /** + * Called by our observer when notified of a form submission. + * [Note that this happens before any DOM onsubmit handlers are invoked.] + * Looks for a password change in the submitted form, so we can update + * our stored password. + * + * @param {FormLike} form + */ + _onFormSubmit(form) { + log("_onFormSubmit", form); + var doc = form.ownerDocument; + var win = doc.defaultView; + + if (PrivateBrowsingUtils.isContentWindowPrivate(win)) { + // We won't do anything in private browsing mode anyway, + // so there's no need to perform further checks. + log("(form submission ignored in private browsing mode)"); + return; + } + + // If password saving is disabled (globally or for host), bail out now. + if (!gEnabled) + return; + + var hostname = LoginUtils._getPasswordOrigin(doc.documentURI); + if (!hostname) { + log("(form submission ignored -- invalid hostname)"); + return; + } + + let formSubmitURL = LoginUtils._getActionOrigin(form); + let messageManager = messageManagerFromWindow(win); + + let recipes = messageManager.sendSyncMessage("RemoteLogins:findRecipes", { + formOrigin: hostname, + })[0]; + + // Get the appropriate fields from the form. + var [usernameField, newPasswordField, oldPasswordField] = + this._getFormFields(form, true, recipes); + + // Need at least 1 valid password field to do anything. + if (newPasswordField == null) + return; + + // Check for autocomplete=off attribute. We don't use it to prevent + // autofilling (for existing logins), but won't save logins when it's + // present and the storeWhenAutocompleteOff pref is false. + // XXX spin out a bug that we don't update timeLastUsed in this case? + if ((this._isAutocompleteDisabled(form) || + this._isAutocompleteDisabled(usernameField) || + this._isAutocompleteDisabled(newPasswordField) || + this._isAutocompleteDisabled(oldPasswordField)) && + !gStoreWhenAutocompleteOff) { + log("(form submission ignored -- autocomplete=off found)"); + return; + } + + // Don't try to send DOM nodes over IPC. + let mockUsername = usernameField ? + { name: usernameField.name, + value: usernameField.value } : + null; + let mockPassword = { name: newPasswordField.name, + value: newPasswordField.value }; + let mockOldPassword = oldPasswordField ? + { name: oldPasswordField.name, + value: oldPasswordField.value } : + null; + + // Make sure to pass the opener's top in case it was in a frame. + let openerTopWindow = win.opener ? win.opener.top : null; + + messageManager.sendAsyncMessage("RemoteLogins:onFormSubmit", + { hostname: hostname, + formSubmitURL: formSubmitURL, + usernameField: mockUsername, + newPasswordField: mockPassword, + oldPasswordField: mockOldPassword }, + { openerTopWindow }); + }, + + /** + * Attempt to find the username and password fields in a form, and fill them + * in using the provided logins and recipes. + * + * @param {LoginForm} form + * @param {bool} autofillForm denotes if we should fill the form in automatically + * @param {bool} clobberUsername controls if an existing username can be overwritten. + * If this is false and an inputElement of type password + * is also passed, the username field will be ignored. + * If this is false and no inputElement is passed, if the username + * field value is not found in foundLogins, it will not fill the password. + * @param {bool} clobberPassword controls if an existing password value can be + * overwritten + * @param {bool} userTriggered is an indication of whether this filling was triggered by + * the user + * @param {nsILoginInfo[]} foundLogins is an array of nsILoginInfo that could be used for the form + * @param {Set} recipes that could be used to affect how the form is filled + * @param {Object} [options = {}] is a list of options for this method. + - [inputElement] is an optional target input element we want to fill + */ + _fillForm(form, autofillForm, clobberUsername, clobberPassword, + userTriggered, foundLogins, recipes, {inputElement} = {}) { + if (form instanceof Ci.nsIDOMHTMLFormElement) { + throw new Error("_fillForm should only be called with FormLike objects"); + } + + log("_fillForm", form.elements); + let ignoreAutocomplete = true; + // Will be set to one of AUTOFILL_RESULT in the `try` block. + let autofillResult = -1; + const AUTOFILL_RESULT = { + FILLED: 0, + NO_PASSWORD_FIELD: 1, + PASSWORD_DISABLED_READONLY: 2, + NO_LOGINS_FIT: 3, + NO_SAVED_LOGINS: 4, + EXISTING_PASSWORD: 5, + EXISTING_USERNAME: 6, + MULTIPLE_LOGINS: 7, + NO_AUTOFILL_FORMS: 8, + AUTOCOMPLETE_OFF: 9, + INSECURE: 10, + }; + + try { + // Nothing to do if we have no matching logins available, + // and there isn't a need to show the insecure form warning. + if (foundLogins.length == 0 && + (InsecurePasswordUtils.isFormSecure(form) || + !LoginHelper.showInsecureFieldWarning)) { + // We don't log() here since this is a very common case. + autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS; + return; + } + + // Heuristically determine what the user/pass fields are + // We do this before checking to see if logins are stored, + // so that the user isn't prompted for a master password + // without need. + var [usernameField, passwordField, ignored] = + this._getFormFields(form, false, recipes); + + // If we have a password inputElement parameter and it's not + // the same as the one heuristically found, use the parameter + // one instead. + if (inputElement) { + if (inputElement.type == "password") { + passwordField = inputElement; + if (!clobberUsername) { + usernameField = null; + } + } else if (LoginHelper.isUsernameFieldType(inputElement)) { + usernameField = inputElement; + } else { + throw new Error("Unexpected input element type."); + } + } + + // Need a valid password field to do anything. + if (passwordField == null) { + log("not filling form, no password field found"); + autofillResult = AUTOFILL_RESULT.NO_PASSWORD_FIELD; + return; + } + + // If the password field is disabled or read-only, there's nothing to do. + if (passwordField.disabled || passwordField.readOnly) { + log("not filling form, password field disabled or read-only"); + autofillResult = AUTOFILL_RESULT.PASSWORD_DISABLED_READONLY; + return; + } + + // Attach autocomplete stuff to the username field, if we have + // one. This is normally used to select from multiple accounts, + // but even with one account we should refill if the user edits. + // We would also need this attached to show the insecure login + // warning, regardless of saved login. + if (usernameField) { + this._formFillService.markAsLoginManagerField(usernameField); + } + + // Nothing to do if we have no matching logins available. + // Only insecure pages reach this block and logs the same + // telemetry flag. + if (foundLogins.length == 0) { + // We don't log() here since this is a very common case. + autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS; + return; + } + + // Prevent autofilling insecure forms. + if (!userTriggered && !LoginHelper.insecureAutofill && + !InsecurePasswordUtils.isFormSecure(form)) { + log("not filling form since it's insecure"); + autofillResult = AUTOFILL_RESULT.INSECURE; + return; + } + + var isAutocompleteOff = false; + if (this._isAutocompleteDisabled(form) || + this._isAutocompleteDisabled(usernameField) || + this._isAutocompleteDisabled(passwordField)) { + isAutocompleteOff = true; + } + + // Discard logins which have username/password values that don't + // fit into the fields (as specified by the maxlength attribute). + // The user couldn't enter these values anyway, and it helps + // with sites that have an extra PIN to be entered (bug 391514) + var maxUsernameLen = Number.MAX_VALUE; + var maxPasswordLen = Number.MAX_VALUE; + + // If attribute wasn't set, default is -1. + if (usernameField && usernameField.maxLength >= 0) + maxUsernameLen = usernameField.maxLength; + if (passwordField.maxLength >= 0) + maxPasswordLen = passwordField.maxLength; + + var logins = foundLogins.filter(function (l) { + var fit = (l.username.length <= maxUsernameLen && + l.password.length <= maxPasswordLen); + if (!fit) + log("Ignored", l.username, "login: won't fit"); + + return fit; + }, this); + + if (logins.length == 0) { + log("form not filled, none of the logins fit in the field"); + autofillResult = AUTOFILL_RESULT.NO_LOGINS_FIT; + return; + } + + // Don't clobber an existing password. + if (passwordField.value && !clobberPassword) { + log("form not filled, the password field was already filled"); + autofillResult = AUTOFILL_RESULT.EXISTING_PASSWORD; + return; + } + + // Select a login to use for filling in the form. + var selectedLogin; + if (!clobberUsername && usernameField && (usernameField.value || + usernameField.disabled || + usernameField.readOnly)) { + // If username was specified in the field, it's disabled or it's readOnly, only fill in the + // password if we find a matching login. + var username = usernameField.value.toLowerCase(); + + let matchingLogins = logins.filter(l => + l.username.toLowerCase() == username); + if (matchingLogins.length == 0) { + log("Password not filled. None of the stored logins match the username already present."); + autofillResult = AUTOFILL_RESULT.EXISTING_USERNAME; + return; + } + + // If there are multiple, and one matches case, use it + for (let l of matchingLogins) { + if (l.username == usernameField.value) { + selectedLogin = l; + } + } + // Otherwise just use the first + if (!selectedLogin) { + selectedLogin = matchingLogins[0]; + } + } else if (logins.length == 1) { + selectedLogin = logins[0]; + } else { + // We have multiple logins. Handle a special case here, for sites + // which have a normal user+pass login *and* a password-only login + // (eg, a PIN). Prefer the login that matches the type of the form + // (user+pass or pass-only) when there's exactly one that matches. + let matchingLogins; + if (usernameField) + matchingLogins = logins.filter(l => l.username); + else + matchingLogins = logins.filter(l => !l.username); + + if (matchingLogins.length != 1) { + log("Multiple logins for form, so not filling any."); + autofillResult = AUTOFILL_RESULT.MULTIPLE_LOGINS; + return; + } + + selectedLogin = matchingLogins[0]; + } + + // We will always have a selectedLogin at this point. + + if (!autofillForm) { + log("autofillForms=false but form can be filled"); + autofillResult = AUTOFILL_RESULT.NO_AUTOFILL_FORMS; + return; + } + + if (isAutocompleteOff && !ignoreAutocomplete) { + log("Not filling the login because we're respecting autocomplete=off"); + autofillResult = AUTOFILL_RESULT.AUTOCOMPLETE_OFF; + return; + } + + // Fill the form + + if (usernameField) { + // Don't modify the username field if it's disabled or readOnly so we preserve its case. + let disabledOrReadOnly = usernameField.disabled || usernameField.readOnly; + + let userNameDiffers = selectedLogin.username != usernameField.value; + // Don't replace the username if it differs only in case, and the user triggered + // this autocomplete. We assume that if it was user-triggered the entered text + // is desired. + let userEnteredDifferentCase = userTriggered && userNameDiffers && + usernameField.value.toLowerCase() == selectedLogin.username.toLowerCase(); + + if (!disabledOrReadOnly && !userEnteredDifferentCase && userNameDiffers) { + usernameField.setUserInput(selectedLogin.username); + } + } + + let doc = form.ownerDocument; + if (passwordField.value != selectedLogin.password) { + passwordField.setUserInput(selectedLogin.password); + let autoFilledLogin = { + guid: selectedLogin.QueryInterface(Ci.nsILoginMetaInfo).guid, + username: selectedLogin.username, + usernameField: usernameField ? Cu.getWeakReference(usernameField) : null, + password: selectedLogin.password, + passwordField: Cu.getWeakReference(passwordField), + }; + log("Saving autoFilledLogin", autoFilledLogin.guid, "for", form.rootElement); + this.stateForDocument(doc).fillsByRootElement.set(form.rootElement, autoFilledLogin); + } + + log("_fillForm succeeded"); + autofillResult = AUTOFILL_RESULT.FILLED; + + let win = doc.defaultView; + let messageManager = messageManagerFromWindow(win); + messageManager.sendAsyncMessage("LoginStats:LoginFillSuccessful"); + } finally { + if (autofillResult == -1) { + // eslint-disable-next-line no-unsafe-finally + throw new Error("_fillForm: autofillResult must be specified"); + } + + if (!userTriggered) { + // Ignore fills as a result of user action for this probe. + Services.telemetry.getHistogramById("PWMGR_FORM_AUTOFILL_RESULT").add(autofillResult); + + if (usernameField) { + let focusedElement = this._formFillService.focusedInput; + if (usernameField == focusedElement && + autofillResult !== AUTOFILL_RESULT.FILLED) { + log("_fillForm: Opening username autocomplete popup since the form wasn't autofilled"); + this._formFillService.showPopup(); + } + } + } + + if (usernameField) { + log("_fillForm: Attaching event listeners to usernameField"); + usernameField.addEventListener("focus", observer); + usernameField.addEventListener("contextmenu", observer); + } + + Services.obs.notifyObservers(form.rootElement, "passwordmgr-processed-form", null); + } + }, + + /** + * Given a field, determine whether that field was last filled as a username + * field AND whether the username is still filled in with the username AND + * whether the associated password field has the matching password. + * + * @note This could possibly be unified with getFieldContext but they have + * slightly different use cases. getFieldContext looks up recipes whereas this + * method doesn't need to since it's only returning a boolean based upon the + * recipes used for the last fill (in _fillForm). + * + * @param {HTMLInputElement} aUsernameField element contained in a FormLike + * cached in _formLikeByRootElement. + * @returns {Boolean} whether the username and password fields still have the + * last-filled values, if previously filled. + */ + _isLoginAlreadyFilled(aUsernameField) { + let formLikeRoot = FormLikeFactory.findRootForField(aUsernameField); + // Look for the existing FormLike. + let existingFormLike = this._formLikeByRootElement.get(formLikeRoot); + if (!existingFormLike) { + throw new Error("_isLoginAlreadyFilled called with a username field with " + + "no rootElement FormLike"); + } + + log("_isLoginAlreadyFilled: existingFormLike", existingFormLike); + let filledLogin = this.stateForDocument(aUsernameField.ownerDocument).fillsByRootElement.get(formLikeRoot); + if (!filledLogin) { + return false; + } + + // Unpack the weak references. + let autoFilledUsernameField = filledLogin.usernameField ? filledLogin.usernameField.get() : null; + let autoFilledPasswordField = filledLogin.passwordField.get(); + + // Check username and password values match what was filled. + if (!autoFilledUsernameField || + autoFilledUsernameField != aUsernameField || + autoFilledUsernameField.value != filledLogin.username || + !autoFilledPasswordField || + autoFilledPasswordField.value != filledLogin.password) { + return false; + } + + return true; + }, + + /** + * Verify if a field is a valid login form field and + * returns some information about it's FormLike. + * + * @param {Element} aField + * A form field we want to verify. + * + * @returns {Object} an object with information about the + * FormLike username and password field + * or null if the passed field is invalid. + */ + getFieldContext(aField) { + // If the element is not a proper form field, return null. + if (!(aField instanceof Ci.nsIDOMHTMLInputElement) || + (aField.type != "password" && !LoginHelper.isUsernameFieldType(aField)) || + !aField.ownerDocument) { + return null; + } + let form = LoginFormFactory.createFromField(aField); + + let doc = aField.ownerDocument; + let messageManager = messageManagerFromWindow(doc.defaultView); + let recipes = messageManager.sendSyncMessage("RemoteLogins:findRecipes", { + formOrigin: LoginUtils._getPasswordOrigin(doc.documentURI), + })[0]; + + let [usernameField, newPasswordField] = + this._getFormFields(form, false, recipes); + + // If we are not verifying a password field, we want + // to use aField as the username field. + if (aField.type != "password") { + usernameField = aField; + } + + return { + usernameField: { + found: !!usernameField, + disabled: usernameField && (usernameField.disabled || usernameField.readOnly), + }, + passwordField: { + found: !!newPasswordField, + disabled: newPasswordField && (newPasswordField.disabled || newPasswordField.readOnly), + }, + }; + }, +}; + +var LoginUtils = { + /** + * Get the parts of the URL we want for identification. + * Strip out things like the userPass portion + */ + _getPasswordOrigin(uriString, allowJS) { + var realm = ""; + try { + var uri = Services.io.newURI(uriString, null, null); + + if (allowJS && uri.scheme == "javascript") + return "javascript:"; + + // Build this manually instead of using prePath to avoid including the userPass portion. + realm = uri.scheme + "://" + uri.hostPort; + } catch (e) { + // bug 159484 - disallow url types that don't support a hostPort. + // (although we handle "javascript:..." as a special case above.) + log("Couldn't parse origin for", uriString, e); + realm = null; + } + + return realm; + }, + + _getActionOrigin(form) { + var uriString = form.action; + + // A blank or missing action submits to where it came from. + if (uriString == "") + uriString = form.baseURI; // ala bug 297761 + + return this._getPasswordOrigin(uriString, true); + }, +}; + +// nsIAutoCompleteResult implementation +function UserAutoCompleteResult(aSearchString, matchingLogins, {isSecure, messageManager, isPasswordField}) { + function loginSort(a, b) { + var userA = a.username.toLowerCase(); + var userB = b.username.toLowerCase(); + + if (userA < userB) + return -1; + + if (userA > userB) + return 1; + + return 0; + } + + function findDuplicates(loginList) { + let seen = new Set(); + let duplicates = new Set(); + for (let login of loginList) { + if (seen.has(login.username)) { + duplicates.add(login.username); + } + seen.add(login.username); + } + return duplicates; + } + + this._showInsecureFieldWarning = (!isSecure && LoginHelper.showInsecureFieldWarning) ? 1 : 0; + this.searchString = aSearchString; + this.logins = matchingLogins.sort(loginSort); + this.matchCount = matchingLogins.length + this._showInsecureFieldWarning; + this._messageManager = messageManager; + this._stringBundle = Services.strings.createBundle("chrome://passwordmgr/locale/passwordmgr.properties"); + this._dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, + { day: "numeric", month: "short", year: "numeric" }); + + this._isPasswordField = isPasswordField; + + this._duplicateUsernames = findDuplicates(matchingLogins); + + if (this.matchCount > 0) { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + this.defaultIndex = 0; + } +} + +UserAutoCompleteResult.prototype = { + QueryInterface : XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult, + Ci.nsISupportsWeakReference]), + + // private + logins : null, + + // Allow autoCompleteSearch to get at the JS object so it can + // modify some readonly properties for internal use. + get wrappedJSObject() { + return this; + }, + + // Interfaces from idl... + searchString : null, + searchResult : Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + defaultIndex : -1, + errorDescription : "", + matchCount : 0, + + getValueAt(index) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + + if (this._showInsecureFieldWarning && index === 0) { + return ""; + } + + let selectedLogin = this.logins[index - this._showInsecureFieldWarning]; + + return this._isPasswordField ? selectedLogin.password : selectedLogin.username; + }, + + getLabelAt(index) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + + if (this._showInsecureFieldWarning && index === 0) { + return this._stringBundle.GetStringFromName("insecureFieldWarningDescription") + " " + + this._stringBundle.GetStringFromName("insecureFieldWarningLearnMore"); + } + + let that = this; + + function getLocalizedString(key, formatArgs) { + if (formatArgs) { + return that._stringBundle.formatStringFromName(key, formatArgs, formatArgs.length); + } + return that._stringBundle.GetStringFromName(key); + } + + let login = this.logins[index - this._showInsecureFieldWarning]; + let username = login.username; + // If login is empty or duplicated we want to append a modification date to it. + if (!username || this._duplicateUsernames.has(username)) { + if (!username) { + username = getLocalizedString("noUsername"); + } + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + let time = this._dateAndTimeFormatter.format(new Date(meta.timePasswordChanged)); + username = getLocalizedString("loginHostAge", [username, time]); + } + + return username; + }, + + getCommentAt(index) { + return ""; + }, + + getStyleAt(index) { + if (index == 0 && this._showInsecureFieldWarning) { + return "insecureWarning"; + } + + return "login"; + }, + + getImageAt(index) { + return ""; + }, + + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + }, + + removeValueAt(index, removeFromDB) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + + if (this._showInsecureFieldWarning && index === 0) { + // Ignore the warning message item. + return; + } + if (this._showInsecureFieldWarning) { + index--; + } + + var [removedLogin] = this.logins.splice(index, 1); + + this.matchCount--; + if (this.defaultIndex > this.logins.length) + this.defaultIndex--; + + if (removeFromDB) { + if (this._messageManager) { + let vanilla = LoginHelper.loginToVanillaObject(removedLogin); + this._messageManager.sendAsyncMessage("RemoteLogins:removeLogin", + { login: vanilla }); + } else { + Services.logins.removeLogin(removedLogin); + } + } + } +}; + +/** + * A factory to generate FormLike objects that represent a set of login fields + * which aren't necessarily marked up with a <form> element. + */ +var LoginFormFactory = { + /** + * Create a LoginForm object from a <form>. + * + * @param {HTMLFormElement} aForm + * @return {LoginForm} + * @throws Error if aForm isn't an HTMLFormElement + */ + createFromForm(aForm) { + let formLike = FormLikeFactory.createFromForm(aForm); + formLike.action = LoginUtils._getActionOrigin(aForm); + + let state = LoginManagerContent.stateForDocument(formLike.ownerDocument); + state.loginFormRootElements.add(formLike.rootElement); + log("adding", formLike.rootElement, "to loginFormRootElements for", formLike.ownerDocument); + + LoginManagerContent._formLikeByRootElement.set(formLike.rootElement, formLike); + return formLike; + }, + + /** + * Create a LoginForm object from a password or username field. + * + * If the field is in a <form>, construct the LoginForm from the form. + * Otherwise, create a LoginForm with a rootElement (wrapper) according to + * heuristics. Currently all <input> not in a <form> are one LoginForm but this + * shouldn't be relied upon as the heuristics may change to detect multiple + * "forms" (e.g. registration and login) on one page with a <form>. + * + * Note that two LoginForms created from the same field won't return the same LoginForm object. + * Use the `rootElement` property on the LoginForm as a key instead. + * + * @param {HTMLInputElement} aField - a password or username field in a document + * @return {LoginForm} + * @throws Error if aField isn't a password or username field in a document + */ + createFromField(aField) { + if (!(aField instanceof Ci.nsIDOMHTMLInputElement) || + (aField.type != "password" && !LoginHelper.isUsernameFieldType(aField)) || + !aField.ownerDocument) { + throw new Error("createFromField requires a password or username field in a document"); + } + + if (aField.form) { + return this.createFromForm(aField.form); + } + + let formLike = FormLikeFactory.createFromField(aField); + formLike.action = LoginUtils._getPasswordOrigin(aField.ownerDocument.baseURI); + log("Created non-form FormLike for rootElement:", aField.ownerDocument.documentElement); + + let state = LoginManagerContent.stateForDocument(formLike.ownerDocument); + state.loginFormRootElements.add(formLike.rootElement); + log("adding", formLike.rootElement, "to loginFormRootElements for", formLike.ownerDocument); + + + LoginManagerContent._formLikeByRootElement.set(formLike.rootElement, formLike); + + return formLike; + }, +}; |