Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
/**
* Module doing most of the content process work for the password manager.
*/
// Disable use-ownerGlobal since LoginForm doesn't have it.
/* eslint-disable mozilla/use-ownerGlobal */
"use strict";
const EXPORTED_SYMBOLS = ["LoginManagerChild", "LoginFormState"];
const PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS = 1;
// The amount of time a context menu event supresses showing a
// popup from a focus event in ms. This matches the threshold in
// toolkit/components/satchel/nsFormFillController.cpp
const AUTOCOMPLETE_AFTER_RIGHT_CLICK_THRESHOLD_MS = 400;
const AUTOFILL_STATE = "autofill";
const SUBMIT_FORM_SUBMIT = 1;
const SUBMIT_PAGE_NAVIGATION = 2;
const SUBMIT_FORM_IS_REMOVED = 3;
const { XPCOMUtils } = ChromeUtils.import(
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { AppConstants } = ChromeUtils.import(
);
const { PrivateBrowsingUtils } = ChromeUtils.import(
);
const { CreditCard } = ChromeUtils.import(
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
LoginRecipesContent: "resource://gre/modules/LoginRecipes.jsm",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gFormFillService",
"@mozilla.org/satchel/form-fill-controller;1",
"nsIFormFillController"
);
XPCOMUtils.defineLazyGetter(lazy, "log", () => {
let logger = lazy.LoginHelper.createLogger("LoginManagerChild");
return logger.log.bind(logger);
});
Services.cpmm.addMessageListener("clearRecipeCache", () => {
lazy.LoginRecipesContent._clearRecipeCache();
});
let gLastRightClickTimeStamp = Number.NEGATIVE_INFINITY;
// Events on pages with Shadow DOM could return the shadow host element
// (aEvent.target) rather than the actual username or password field
// (aEvent.composedTarget).
// Only allow input elements (can be extended later) to avoid false negatives.
class WeakFieldSet extends WeakSet {
add(value) {
if (!HTMLInputElement.isInstance(value)) {
throw new Error("Non-field type added to a WeakFieldSet");
}
super.add(value);
}
}
const observer = {
QueryInterface: ChromeUtils.generateQI([
"nsIObserver",
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
// 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;
}
const window = aWebProgress.DOMWindow;
lazy.log(
"onLocationChange handled:",
aLocation.displaySpec,
window.document
);
LoginManagerChild.forWindow(window)._onNavigation(window.document);
},
onStateChange(aWebProgress, aRequest, aState, aStatus) {
const window = aWebProgress.DOMWindow;
const loginManagerChild = () => LoginManagerChild.forWindow(window);
if (
aState & Ci.nsIWebProgressListener.STATE_RESTORING &&
aState & Ci.nsIWebProgressListener.STATE_STOP
) {
// Re-fill a document restored from bfcache since password field values
// aren't persisted there.
loginManagerChild()._onDocumentRestored(window.document);
return;
}
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)) {
lazy.log(
"onStateChange: loadType isn't LOAD_CMD_NORMAL:",
aWebProgress.loadType
);
return;
}
lazy.log("onStateChange handled:", channel);
loginManagerChild()._onNavigation(window.document);
},
// nsIObserver
observe(subject, topic, data) {
switch (topic) {
case "autocomplete-did-enter-text": {
let input = subject.QueryInterface(Ci.nsIAutoCompleteInput);
let { selectedIndex } = input.popup;
if (selectedIndex < 0) {
break;
}
let { focusedInput } = lazy.gFormFillService;
if (focusedInput.nodePrincipal.isNullPrincipal) {
// If we have a null principal then prevent any more password manager code from running and
// incorrectly using the document `location`.
return;
}
let window = focusedInput.ownerGlobal;
let loginManagerChild = LoginManagerChild.forWindow(window);
let style = input.controller.getStyleAt(selectedIndex);
if (style == "login" || style == "loginWithOrigin") {
let details = JSON.parse(
input.controller.getCommentAt(selectedIndex)
);
loginManagerChild.onFieldAutoComplete(focusedInput, details.guid);
} else if (style == "generatedPassword") {
loginManagerChild._filledWithGeneratedPassword(focusedInput);
}
break;
}
}
},
// nsIDOMEventListener
handleEvent(aEvent) {
if (!aEvent.isTrusted) {
return;
}
if (!lazy.LoginHelper.enabled) {
return;
}
const ownerDocument = aEvent.target.ownerDocument;
const window = ownerDocument.defaultView;
const loginManagerChild = LoginManagerChild.forWindow(window);
const docState = loginManagerChild.stateForDocument(ownerDocument);
const field = aEvent.composedTarget;
switch (aEvent.type) {
// Used to mask fields with filled generated passwords when blurred.
case "blur": {
if (docState.generatedPasswordFields.has(field)) {
docState._togglePasswordFieldMasking(field, false);
}
break;
}
// Used to watch for changes to username and password fields.
case "change": {
let formLikeRoot = lazy.FormLikeFactory.findRootForField(field);
if (!docState.fieldModificationsByRootElement.get(formLikeRoot)) {
lazy.log(
"Ignoring change event on form that hasn't been user-modified"
);
if (field.hasBeenTypePassword) {
// Send notification that the password field has not been changed.
// This is used only for testing.
loginManagerChild._ignorePasswordEdit();
}
break;
}
docState.storeUserInput(field);
let detail = {
possibleValues: {
usernames: docState.possibleUsernames,
passwords: docState.possiblePasswords,
},
};
loginManagerChild.sendAsyncMessage(
"PasswordManager:updateDoorhangerSuggestions",
detail
);
if (field.hasBeenTypePassword) {
let triggeredByFillingGenerated = docState.generatedPasswordFields.has(
field
);
// Autosave generated password initial fills and subsequent edits
if (triggeredByFillingGenerated) {
loginManagerChild._passwordEditedOrGenerated(field, {
triggeredByFillingGenerated,
});
} else {
// Send a notification that we are not saving the edit to the password field.
// This is used only for testing.
loginManagerChild._ignorePasswordEdit();
}
}
break;
}
case "input": {
let isPasswordType = lazy.LoginHelper.isPasswordFieldType(field);
// React to input into fields filled with generated passwords.
if (
docState.generatedPasswordFields.has(field) &&
// Depending on the edit, we may no longer want to consider
// the field a generated password field to avoid autosaving.
loginManagerChild._doesEventClearPrevFieldValue(aEvent)
) {
docState._stopTreatingAsGeneratedPasswordField(field);
}
if (!isPasswordType && !lazy.LoginHelper.isUsernameFieldType(field)) {
break;
}
// React to input into potential username or password fields
let formLikeRoot = lazy.FormLikeFactory.findRootForField(field);
if (formLikeRoot !== aEvent.currentTarget) {
break;
}
// flag this form as user-modified for the closest form/root ancestor
let alreadyModified = docState.fieldModificationsByRootElement.get(
formLikeRoot
);
let { login: filledLogin, userTriggered: fillWasUserTriggered } =
docState.fillsByRootElement.get(formLikeRoot) || {};
// don't flag as user-modified if the form was autofilled and doesn't appear to have changed
let isAutofillInput = filledLogin && !fillWasUserTriggered;
if (!alreadyModified && isAutofillInput) {
if (isPasswordType && filledLogin.password == field.value) {
lazy.log(
"Ignoring password input event that doesn't change autofilled values"
);
break;
}
if (
!isPasswordType &&
filledLogin.usernameField &&
filledLogin.username == field.value
) {
lazy.log(
"Ignoring username input event that doesn't change autofilled values"
);
break;
}
}
docState.fieldModificationsByRootElement.set(formLikeRoot, true);
// Keep track of the modified formless password field to trigger form submission
// when it is removed from DOM.
let alreadyModifiedFormLessField = true;
if (!HTMLFormElement.isInstance(formLikeRoot)) {
alreadyModifiedFormLessField = docState.formlessModifiedPasswordFields.has(
field
);
if (!alreadyModifiedFormLessField) {
docState.formlessModifiedPasswordFields.add(field);
}
}
// Infer form submission only when there has been an user interaction on the form
// or the formless password field.
if (
lazy.LoginHelper.formRemovalCaptureEnabled &&
(!alreadyModified || !alreadyModifiedFormLessField)
) {
ownerDocument.setNotifyFetchSuccess(true);
}
if (
// When the password field value is cleared or entirely replaced we don't treat it as
// an autofilled form any more. We don't do the same for username edits to avoid snooping
// on the autofilled password in the resulting doorhanger
isPasswordType &&
loginManagerChild._doesEventClearPrevFieldValue(aEvent) &&
// Don't clear last recorded autofill if THIS is an autofilled value. This will be true
// when filling from the context menu.
filledLogin &&
filledLogin.password !== field.value
) {
docState.fillsByRootElement.delete(formLikeRoot);
}
if (!lazy.LoginHelper.passwordEditCaptureEnabled) {
break;
}
if (field.hasBeenTypePassword) {
// When a field is filled with a generated password, we also fill a confirm password field
// if found. To do this, _fillConfirmFieldWithGeneratedPassword calls setUserInput, which fires
// an "input" event on the confirm password field. compareAndUpdatePreviouslySentValues will
// allow that message through due to triggeredByFillingGenerated, so early return here.
let form = lazy.LoginFormFactory.createFromField(field);
if (
docState.generatedPasswordFields.has(field) &&
docState._getFormFields(form).confirmPasswordField === field
) {
break;
}
// Don't check for triggeredByFillingGenerated, as we do not want to autosave
// a field marked as a generated password field on every "input" event
loginManagerChild._passwordEditedOrGenerated(field);
} else {
let [
usernameField,
passwordField,
] = docState.getUserNameAndPasswordFields(field);
if (field == usernameField && passwordField?.value) {
loginManagerChild._passwordEditedOrGenerated(passwordField, {
triggeredByFillingGenerated: docState.generatedPasswordFields.has(
passwordField
),
});
}
}
break;
}
case "keydown": {
if (
field.value &&
(aEvent.keyCode == aEvent.DOM_VK_TAB ||
aEvent.keyCode == aEvent.DOM_VK_RETURN)
) {
const autofillForm =
lazy.LoginHelper.autofillForms &&
!PrivateBrowsingUtils.isContentWindowPrivate(
ownerDocument.defaultView
);
if (autofillForm) {
loginManagerChild.onUsernameAutocompleted(field);
}
}
break;
}
case "focus": {
//@sg see if we can drop focusedField (aEvent.target) and use field (aEvent.composedTarget)
docState.onFocus(field, aEvent.target);
break;
}
case "mousedown": {
if (aEvent.button == 2) {
// Date.now() is used instead of event.timeStamp since
// dom.event.highrestimestamp.enabled isn't true on all channels yet.
gLastRightClickTimeStamp = Date.now();
}
break;
}
default: {
throw new Error("Unexpected event");
}
}
},
};
// Add this observer once for the process.
Services.obs.addObserver(observer, "autocomplete-did-enter-text");
/**
* Logic of Capture and Filling.
*
* This class will be shared with Firefox iOS and should have no references to
* Gecko internals. See Bug 1774208.
*/
class LoginFormState {
/**
* Keeps track of filled fields and values.
*/
fillsByRootElement = new WeakMap();
/**
* Keeps track of fields we've filled with generated passwords
*/
generatedPasswordFields = new WeakFieldSet();
/**
* Keeps track of logins that were last submitted.
*/
lastSubmittedValuesByRootElement = new WeakMap();
fieldModificationsByRootElement = new WeakMap();
loginFormRootElements = new WeakSet();
/**
* Anything entered into an <input> that we think might be a username
*/
possibleUsernames = new Set();
/**
* Anything entered into an <input> that we think might be a password
*/
possiblePasswords = new Set();
/**
* Keeps track of the formLike of nodes (form or formless password field)
* that we are watching when they are removed from DOM.
*/
formLikeByObservedNode = new WeakMap();
/**
* Keeps track of all formless password fields that have been
* updated by the user.
*/
formlessModifiedPasswordFields = new WeakFieldSet();
/**
* Caches the results of the username heuristics
*/
#cachedIsInferredUsernameField = new WeakMap();
cachedIsInferredEmailField = new WeakMap();
#cachedIsInferredLoginForm = new WeakMap();
/**
* Records the mock username field when its associated form is submitted.
*/
mockUsernameOnlyField = null;
/**
* Records the number of possible username event received for this document.
*/
numFormHasPossibleUsernameEvent = 0;
captureLoginTimeStamp = 0;
storeUserInput(field) {
if (field.value && lazy.LoginHelper.captureInputChanges) {
if (lazy.LoginHelper.isPasswordFieldType(field)) {
this.possiblePasswords.add(field.value);
} else if (lazy.LoginHelper.isUsernameFieldType(field)) {
this.possibleUsernames.add(field.value);
}
}
}
/**
* Returns true if the input field is considered an email field by
* 'LoginHelper.isInferredEmailField'.
*
* @param {Element} element the field to check.
* @returns {boolean} True if the element is likely an email field
*/
isProbablyAnEmailField(inputElement) {
let result = this.cachedIsInferredEmailField.get(inputElement);
if (result === undefined) {
result = lazy.LoginHelper.isInferredEmailField(inputElement);
this.cachedIsInferredEmailField.set(inputElement, result);
}
return result;
}
/**
* Returns true if the input field is considered a username field by
* 'LoginHelper.isInferredUsernameField'. The main purpose of this method
* is to cache the result because _getFormFields has many call sites and we
* want to avoid applying the heuristic every time.
*
* @param {Element} element the field to check.
* @returns {boolean} True if the element is likely a username field
*/
isProbablyAUsernameField(inputElement) {
let result = this.#cachedIsInferredUsernameField.get(inputElement);
if (result === undefined) {
result = lazy.LoginHelper.isInferredUsernameField(inputElement);
this.#cachedIsInferredUsernameField.set(inputElement, result);
}
return result;
}
/**
* Returns true if the form is considered a username login form if
* 1. The input element looks like a username field or the form looks
* like a login form
* 2. The input field doesn't match keywords that indicate the username
* is not used for login (ex, search) or the login form is not use
* a username to sign-in (ex, authentication code)
*
* @param {Element} element the form to check.
* @returns {boolean} True if the element is likely a login form
*/
#isProbablyAUsernameLoginForm(formElement, inputElement) {
let result = this.#cachedIsInferredLoginForm.get(formElement);
if (result === undefined) {
// We should revisit these rules after we collect more positive or negative
// cases for username-only forms. Right now, if-else-based rules are good
// enough to cover the sites we know, but if we find out defining "weight" for each
// rule is necessary to improve the heuristic, we should consider switching
// this with Fathom.
result = false;
// Check whether the input field looks like a username field or the
// form looks like a sign-in or sign-up form.
if (
this.isProbablyAUsernameField(inputElement) ||
lazy.LoginHelper.isInferredLoginForm(formElement)
) {
// This is where we collect hints that indicate this is not a username
// login form.
if (!lazy.LoginHelper.isInferredNonUsernameField(inputElement)) {
result = true;
}
}
this.#cachedIsInferredLoginForm.set(formElement, result);
}
return result;
}
/**
* 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 LoginForm
* cached in LoginFormFactory.
* @returns {Boolean} whether the username and password fields still have the
* last-filled values, if previously filled.
*/
#isLoginAlreadyFilled(aUsernameField) {
let formLikeRoot = lazy.FormLikeFactory.findRootForField(aUsernameField);
// Look for the existing LoginForm.
let existingLoginForm = lazy.LoginFormFactory.getForRootElement(
formLikeRoot
);
if (!existingLoginForm) {
throw new Error(
"#isLoginAlreadyFilled called with a username field with " +
"no rootElement LoginForm"
);
}
lazy.log("#isLoginAlreadyFilled: existingLoginForm", existingLoginForm);
let { login: filledLogin } =
this.fillsByRootElement.get(formLikeRoot) || {};
if (!filledLogin) {
return false;
}
// Unpack the weak references.
let autoFilledUsernameField = filledLogin.usernameField?.get();
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;
}
_togglePasswordFieldMasking(passwordField, unmask) {
let { editor } = passwordField;
if (passwordField.type != "password") {
// The type may have been changed by the website.
lazy.log("_togglePasswordFieldMasking: Field isn't type=password");
return;
}
if (!unmask && !editor) {
// It hasn't been created yet but the default is to be masked anyways.
return;
}
if (unmask) {
editor.unmask(0);
return;
}
if (editor.autoMaskingEnabled) {
return;
}
editor.mask();
}
/**
* Track a form field as has having been filled with a generated password. This adds explicit
* focus & blur handling to unmask & mask the value, and enables special handling of edits to
* generated password values (see the observer's input event handler.)
*
* @param {HTMLInputElement} passwordField
*/
_treatAsGeneratedPasswordField(passwordField) {
this.generatedPasswordFields.add(passwordField);
// blur/focus: listen for focus changes to we can mask/unmask generated passwords
for (let eventType of ["blur", "focus"]) {
passwordField.addEventListener(eventType, observer, {
capture: true,
mozSystemGroup: true,
});
}
if (passwordField.ownerDocument.activeElement == passwordField) {
// Unmask the password field
this._togglePasswordFieldMasking(passwordField, true);
}
}
_formHasModifiedFields(form) {
const doc = form.rootElement.ownerDocument;
let userHasInteracted;
const testOnlyUserHasInteracted =
lazy.LoginHelper.testOnlyUserHasInteractedWithDocument;
if (Cu.isInAutomation && testOnlyUserHasInteracted !== null) {
userHasInteracted = testOnlyUserHasInteracted;
} else {
userHasInteracted =
!lazy.LoginHelper.userInputRequiredToCapture ||
this.captureLoginTimeStamp != doc.lastUserGestureTimeStamp;
}
lazy.log("_formHasModifiedFields, userHasInteracted:", userHasInteracted);
// Skip if user didn't interact with the page since last call or ever
if (!userHasInteracted) {
return false;
}
// check for user inputs to the form fields
let fieldsModified = this.fieldModificationsByRootElement.get(
form.rootElement
);
// also consider a form modified if there's a difference between fields' .value and .defaultValue
if (!fieldsModified) {
fieldsModified = Array.from(form.elements).some(
field =>
field.defaultValue !== undefined && field.value !== field.defaultValue
);
}
return fieldsModified;
}
_stopTreatingAsGeneratedPasswordField(passwordField) {
lazy.log("_stopTreatingAsGeneratedPasswordField");
this.generatedPasswordFields.delete(passwordField);
// Remove all the event listeners added in _passwordEditedOrGenerated
for (let eventType of ["blur", "focus"]) {
passwordField.removeEventListener(eventType, observer, {
capture: true,
mozSystemGroup: true,
});
}
// Mask the password field
this._togglePasswordFieldMasking(passwordField, false);
}
onFocus(field, focusedField) {
if (field.hasBeenTypePassword && this.generatedPasswordFields.has(field)) {
// Used to unmask fields with filled generated passwords when focused.
this._togglePasswordFieldMasking(field, true);
return;
}
// Only used for username fields.
this.#onUsernameFocus(focusedField);
}
/**
* Focus event handler for username fields to decide whether to show autocomplete.
* @param {HTMLInputElement} focusedField
*/
#onUsernameFocus(focusedField) {
if (
!focusedField.mozIsTextField(true) ||
focusedField.hasBeenTypePassword ||
focusedField.readOnly
) {
return;
}
if (this.#isLoginAlreadyFilled(focusedField)) {
lazy.log("#onUsernameFocus: Already filled");
return;
}
/*
* A `mousedown` event is fired before the `focus` event if the 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 check against the timestamp that was set by the `mousedown` event if the
* button code indicated a right click.
* We use a timestamp instead of a bool to avoid complexity when dealing with multiple input
* forms and the fact that a mousedown into an already focused field does not trigger another focus.
* Date.now() is used instead of event.timeStamp since dom.event.highrestimestamp.enabled isn't
* true on all channels yet.
*/
let timeDiff = Date.now() - gLastRightClickTimeStamp;
if (timeDiff < AUTOCOMPLETE_AFTER_RIGHT_CLICK_THRESHOLD_MS) {
lazy.log(
"Not opening autocomplete after focus since a context menu was opened within",
timeDiff,
"ms"
);
return;
}
lazy.log("maybeOpenAutocompleteAfterFocus: Opening the autocomplete popup");
lazy.gFormFillService.showPopup();
}
/** Remove login field highlight when its value is cleared or overwritten.
*/
static #removeFillFieldHighlight(event) {
let winUtils = event.target.ownerGlobal.windowUtils;
winUtils.removeManuallyManagedState(event.target, AUTOFILL_STATE);
}
/**
* Highlight login fields on autocomplete or autofill on page load.
* @param {Node} element that needs highlighting.
*/
static _highlightFilledField(element) {
let winUtils = element.ownerGlobal.windowUtils;
winUtils.addManuallyManagedState(element, AUTOFILL_STATE);
// Remove highlighting when the field is changed.
element.addEventListener(
"input",
LoginFormState.#removeFillFieldHighlight,
{
mozSystemGroup: true,
once: true,
}
);
}
/**
* Returns the username field of the passed form if the form is a
* username-only form.
* A form is considered a username-only form only if it meets all the
* following conditions:
* 1. Does not have any password field,
* 2. Only contains one input field whose type is username compatible.
* 3. The username compatible input field looks like a username field
* or the form itself looks like a sign-in or sign-up form.
*
* @param {Element} formElement
* the form to check.
* @param {Object} recipe=null
* A relevant field override recipe to use.
* @returns {Element} The username field or null (if the form is not a
* username-only form).
*/
getUsernameFieldFromUsernameOnlyForm(formElement, recipe = null) {
if (!HTMLFormElement.isInstance(formElement)) {
return null;
}
let candidate = null;
for (let element of formElement.elements) {
// We are looking for a username-only form, so if there is a password
// field in the form, this is NOT a username-only form.
if (element.hasBeenTypePassword) {
return null;
}
// Ignore input fields whose type are not username compatiable, ex, hidden.
if (!lazy.LoginHelper.isUsernameFieldType(element)) {
continue;
}
if (
recipe?.notUsernameSelector &&
element.matches(recipe.notUsernameSelector)
) {
continue;
}
// If there are more than two input fields whose type is username
// compatiable, this is NOT a username-only form.
if (candidate) {
return null;
}
candidate = element;
}
if (
candidate &&
this.#isProbablyAUsernameLoginForm(formElement, candidate)
) {
return candidate;
}
return null;
}
/**
* @param {LoginForm} form - the LoginForm to look for password fields in.
* @param {Object} options
* @param {bool} [options.skipEmptyFields=false] - Whether to ignore password fields with no value.
* Used at capture time since saving empty values isn't
* useful.
* @param {Object} [options.fieldOverrideRecipe=null] - A relevant field override recipe to use.
* @return {Array|null} Array of password field elements for the specified form.
* If no pw fields are found, or if more than 5 are found, then null
* is returned.
*/
static _getPasswordFields(
form,
{
fieldOverrideRecipe = null,
minPasswordLength = 0,
ignoreConnect = 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 (
!HTMLInputElement.isInstance(element) ||
!element.hasBeenTypePassword ||
(!element.isConnected && !ignoreConnect)
) {
continue;
}
// Exclude ones matching a `notPasswordSelector`, if specified.
if (
fieldOverrideRecipe?.notPasswordSelector &&
element.matches(fieldOverrideRecipe.notPasswordSelector)
) {
lazy.log(
"skipping password field (id/name is",
element.id,
" / ",
element.name + ") due to recipe:",
fieldOverrideRecipe
);
continue;
}
// XXX: Bug 780449 tracks our handling of emoji and multi-code-point characters in
// password fields. To avoid surprises, we should be consistent with the visual
// representation of the masked password
if (
minPasswordLength &&
element.value.trim().length < minPasswordLength
) {
lazy.log(
"skipping password field (id/name is",
element.id,
" / ",
element.name + ") as value is too short:",
element.value.trim().length
);
continue; // Ignore empty or too-short passwords fields
}
pwFields[pwFields.length] = {
index: i,
element,
};
}
// If too few or too many fields, bail out.
if (!pwFields.length) {
lazy.log("(form ignored -- no password fields.)");
return null;
}
if (pwFields.length > 5) {
lazy.log(
"(form ignored -- too many password fields. [ got ",
pwFields.length,
"])"
);
return null;
}
return pwFields;
}
/**
* Stores passed arguments, and returns whether or not they match the args given the last time
* this method was called with the same [formLikeRoot]. This is used to avoid sending duplicate
* messages to the parent.
*
* @param {Element} formLikeRoot
* @param {string} usernameValue
* @param {string} passwordValue
* @param {boolean?} [dismissed=false]
* @param {boolean?} [triggeredByFillingGenerated=false] whether or not this call was triggered by a generated
* password being filled into a form-like element.
*
* @returns {boolean} true if args match the most recently passed values
*/
compareAndUpdatePreviouslySentValues(
formLikeRoot,
usernameValue,
passwordValue,
dismissed = false,
triggeredByFillingGenerated = false
) {
const lastSentValues = this.lastSubmittedValuesByRootElement.get(
formLikeRoot
);
if (lastSentValues) {
if (dismissed && !lastSentValues.dismissed) {
// preserve previous dismissed value if it was false (i.e. shown/open)
dismissed = false;
}
if (
lastSentValues.username == usernameValue &&
lastSentValues.password == passwordValue &&
lastSentValues.dismissed == dismissed &&
lastSentValues.triggeredByFillingGenerated ==
triggeredByFillingGenerated
) {
lazy.log(
"compareAndUpdatePreviouslySentValues: values are equivalent, returning true"
);
return true;
}
}
// Save the last submitted values so we don't prompt twice for the same values using
// different capture methods e.g. a form submit event and upon navigation.
this.lastSubmittedValuesByRootElement.set(formLikeRoot, {
username: usernameValue,
password: passwordValue,
dismissed,
triggeredByFillingGenerated,
});
lazy.log(
"compareAndUpdatePreviouslySentValues: values not equivalent, returning false"
);
return false;
}
fillConfirmFieldWithGeneratedPassword(passwordField) {
// Fill a nearby password input if it looks like a confirm-password field
let form = lazy.LoginFormFactory.createFromField(passwordField);
let confirmPasswordInput = null;
// The confirm-password field shouldn't be more than 3 form elements away from the password field we filled
let MAX_CONFIRM_PASSWORD_DISTANCE = 3;
let startIndex = form.elements.indexOf(passwordField);
if (startIndex == -1) {
throw new Error(
"Password field is not in the form's elements collection"
);
}
// If we've already filled another field with a generated password,
// this might be the confirm-password field, so don't try and find another
let previousGeneratedPasswordField = form.elements.some(
inp => inp !== passwordField && this.generatedPasswordFields.has(inp)
);
if (previousGeneratedPasswordField) {
lazy.log(
"fillConfirmFieldWithGeneratedPassword, previously-filled generated password input found"
);
return;
}
// Get a list of input fields to search in.
// Pre-filter type=hidden fields; they don't count against the distance threshold
let afterFields = form.elements
.slice(startIndex + 1)
.filter(elem => elem.type !== "hidden");
let acFieldName = passwordField.getAutocompleteInfo()?.fieldName;
// Match same autocomplete values first
if (acFieldName == "new-password") {
let matchIndex = afterFields.findIndex(
elem =>
lazy.LoginHelper.isPasswordFieldType(elem) &&
elem.getAutocompleteInfo().fieldName == acFieldName &&
!elem.disabled &&
!elem.readOnly
);
if (matchIndex >= 0 && matchIndex < MAX_CONFIRM_PASSWORD_DISTANCE) {
confirmPasswordInput = afterFields[matchIndex];
}
}
if (!confirmPasswordInput) {
for (
let idx = 0;
idx < Math.min(MAX_CONFIRM_PASSWORD_DISTANCE, afterFields.length);
idx++
) {
if (
lazy.LoginHelper.isPasswordFieldType(afterFields[idx]) &&
!afterFields[idx].disabled &&
!afterFields[idx].readOnly
) {
confirmPasswordInput = afterFields[idx];
break;
}
}
}
if (confirmPasswordInput && !confirmPasswordInput.value) {
this._treatAsGeneratedPasswordField(confirmPasswordInput);
confirmPasswordInput.setUserInput(passwordField.value);
LoginFormState._highlightFilledField(confirmPasswordInput);
}
}
/**
* 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 {LoginForm} form
* @param {bool} isSubmission
* @param {Set} recipes
* @param {Object} options
* @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
* of the element.
* @return {Object} {usernameField, newPasswordField, oldPasswordField, confirmPasswordField}
*
* usernameField may be null.
* newPasswordField may be null. If null, this is a username-only form.
* 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 LoginForm from a text field,
* this method will only return a non-null usernameField if the
* LoginForm has a password field.
*/
_getFormFields(form, isSubmission, recipes, { ignoreConnect = false } = {}) {
let usernameField = null;
let newPasswordField = null;
let oldPasswordField = null;
let confirmPasswordField = null;
let emptyResult = {
usernameField: null,
newPasswordField: null,
oldPasswordField: null,
confirmPasswordField: null,
};
let pwFields = null;
let fieldOverrideRecipe = lazy.LoginRecipesContent.getFieldOverrides(
recipes,
form
);
if (fieldOverrideRecipe) {
lazy.log("Has fieldOverrideRecipe", fieldOverrideRecipe);
let pwOverrideField = lazy.LoginRecipesContent.queryLoginField(
form,
fieldOverrideRecipe.passwordSelector
);
if (pwOverrideField) {
lazy.log("Has pwOverrideField", pwOverrideField);
// The field from the password override may be in a different LoginForm.
let formLike = lazy.LoginFormFactory.createFromField(pwOverrideField);
pwFields = [
{
index: [...formLike.elements].indexOf(pwOverrideField),
element: pwOverrideField,
},
];
}
let usernameOverrideField = lazy.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.
const minSubmitPasswordLength = 2;
pwFields = LoginFormState._getPasswordFields(form, {
fieldOverrideRecipe,
minPasswordLength: isSubmission ? minSubmitPasswordLength : 0,
ignoreConnect,
});
}
// Check whether this is a username-only form when the form doesn't have
// a password field. Note that recipes are not supported in username-only
// forms currently (Bug 1708455).
if (!pwFields) {
if (!lazy.LoginHelper.usernameOnlyFormEnabled) {
return emptyResult;
}
usernameField = this.getUsernameFieldFromUsernameOnlyForm(
form.rootElement,
fieldOverrideRecipe
);
if (usernameField) {
let acFieldName = usernameField.getAutocompleteInfo().fieldName;
lazy.log(
"Username field ",
usernameField,
"has name/value/autocomplete:",
usernameField.name,
"/",
usernameField.value,
"/",
acFieldName
);
}
return {
...emptyResult,
usernameField,
};
}
if (!usernameField) {
// Searching backwards from the first password field until we find a field
// that looks like a "username" field. If no "username" field is found,
// consider an email-like field a username field, if any.
// If neither a username-like or an email-like field exists, assume the
// first text field before the password field is the username.
// We might not find a username field if the user is already logged in to the site.
//
// Note: We only search fields precede the first password field because we
// don't see sites putting a username field after a password field. We can
// extend searching to all fields in the form if this turns out not to be the case.
for (let i = pwFields[0].index - 1; i >= 0; i--) {
let element = form.elements[i];
if (!lazy.LoginHelper.isUsernameFieldType(element, { ignoreConnect })) {
continue;
}
if (
fieldOverrideRecipe?.notUsernameSelector &&
element.matches(fieldOverrideRecipe.notUsernameSelector)
) {
continue;
}
// Assume the first text field is the username by default.
// It will be replaced if we find a likely username field afterward.
if (!usernameField) {
usernameField = element;
}
if (this.isProbablyAUsernameField(element)) {
// An username field is found, we are done.
usernameField = element;
break;
} else if (this.isProbablyAnEmailField(element)) {
// An email field is found, consider it a username field but continue
// to search for an "username" field.
// In current implementation, if another email field is found during
// the process, we will use the new one.
usernameField = element;
}
}
}
if (!usernameField) {
lazy.log("(form -- no username field found)");
} else {
let acFieldName = usernameField.getAutocompleteInfo().fieldName;
lazy.log(
"Username field ",
usernameField,
"has name/value/autocomplete:",
usernameField.name,
"/",
usernameField.value,
"/",
acFieldName
);
}
let pwGeneratedFields = pwFields.filter(pwField =>
this.generatedPasswordFields.has(pwField.element)
);
if (pwGeneratedFields.length) {
// we have at least the newPasswordField
[newPasswordField, confirmPasswordField] = pwGeneratedFields.map(
pwField => pwField.element
);
// if the user filled a field with a generated password,
// a field immediately previous to that is most likely the old password field
let idx = pwFields.findIndex(
pwField => pwField.element === newPasswordField
);
if (idx > 0) {
oldPasswordField = pwFields[idx - 1].element;
}
return {
...emptyResult,
usernameField,
newPasswordField,
oldPasswordField: oldPasswordField || null,
confirmPasswordField: confirmPasswordField || null,
};
}
// 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) {
let passwordField = pwFields[0].element;
lazy.log(
"Password field",
passwordField,
"has name: ",
passwordField.name
);
return {
...emptyResult,
usernameField,
newPasswordField: passwordField,
oldPasswordField: null,
};
}
// We're looking for both new and old password field
// Try to figure out what is in the form based on the password values.
let pw1 = pwFields[0].element.value;
let pw2 = pwFields[1] ? pwFields[1].element.value : null;
let 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.
lazy.log("(form ignored -- all 3 pw fields differ)");
return emptyResult;
}
} 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;
}
lazy.log(
"Password field (new) id/name is: ",
newPasswordField.id,
" / ",
newPasswordField.name
);
if (oldPasswordField) {
lazy.log(
"Password field (old) id/name is: ",
oldPasswordField.id,
" / ",
oldPasswordField.name
);
} else {
lazy.log("Password field (old):", oldPasswordField);
}
return {
...emptyResult,
usernameField,
newPasswordField,
oldPasswordField,
};
}
/**
* Returns the username and password fields found in the form by input
* element into form.
*
* @param {HTMLInputElement} aField
* A form field
* @return {Array} [usernameField, newPasswordField, oldPasswordField]
*
* Details of these values are the same as _getFormFields.
*/
getUserNameAndPasswordFields(aField) {
const noResult = [null, null, null];
if (!HTMLInputElement.isInstance(aField)) {
throw new Error("getUserNameAndPasswordFields: input element required");
}
if (aField.nodePrincipal.isNullPrincipal || !aField.isConnected) {
return noResult;
}
// If the element is not a login form field, return all null.
if (
!aField.hasBeenTypePassword &&
!lazy.LoginHelper.isUsernameFieldType(aField)
) {
return noResult;
}
const form = lazy.LoginFormFactory.createFromField(aField);
const doc = aField.ownerDocument;
const formOrigin = lazy.LoginHelper.getLoginOrigin(doc.documentURI);
const recipes = lazy.LoginRecipesContent.getRecipes(
formOrigin,
doc.defaultView
);
const {
usernameField,
newPasswordField,
oldPasswordField,
} = this._getFormFields(form, false, recipes);
return [usernameField, newPasswordField, oldPasswordField];
}
/**
* Verify if a field is a valid login form field and
* returns some information about it's LoginForm.
*
* @param {Element} aField
* A form field we want to verify.
*
* @returns {Object} an object with information about the
* LoginForm 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 (
!HTMLInputElement.isInstance(aField) ||
(!aField.hasBeenTypePassword &&
!lazy.LoginHelper.isUsernameFieldType(aField)) ||
aField.nodePrincipal.isNullPrincipal ||
aField.nodePrincipal.schemeIs("about") ||
!aField.ownerDocument
) {
return null;
}
let { hasBeenTypePassword } = aField;
// This array provides labels that correspond to the return values from
// `getUserNameAndPasswordFields` so we can know which one aField is.
const LOGIN_FIELD_ORDER = ["username", "new-password", "current-password"];
let usernameAndPasswordFields = this.getUserNameAndPasswordFields(aField);
let fieldNameHint;
let indexOfFieldInUsernameAndPasswordFields = usernameAndPasswordFields.indexOf(
aField
);
if (indexOfFieldInUsernameAndPasswordFields == -1) {
// For fields in the form that are neither username nor password,
// set fieldNameHint to "other". Right now, in contextmenu, we treat both
// "username" and "other" field as username fields.
fieldNameHint = hasBeenTypePassword ? "current-password" : "other";
} else {
fieldNameHint =
LOGIN_FIELD_ORDER[indexOfFieldInUsernameAndPasswordFields];
}
let [, newPasswordField] = usernameAndPasswordFields;
return {
activeField: {
disabled: aField.disabled || aField.readOnly,
fieldNameHint,
},
// `passwordField` may be the same as `activeField`.
passwordField: {
found: !!newPasswordField,
disabled:
newPasswordField &&
(newPasswordField.disabled || newPasswordField.readOnly),
},
};
}
}
/**
* Integration with browser and IPC with LoginManagerParent.
*
* NOTE: there are still bits of code here that needs to be moved to
* LoginFormState.
*/
class LoginManagerChild extends JSWindowActorChild {
/**
* WeakMap of the root element of a LoginForm to the DeferredTask to fill its fields.
*
* This is used to be able to throttle fills for a LoginForm since onDOMInputPasswordAdded gets
* dispatched for each password field added to a document but we only want to fill once per
* LoginForm when multiple fields are added at once.
*
* @type {WeakMap}
*/
#deferredPasswordAddedTasksByRootElement = new WeakMap();
/**
* WeakMap of a document to the array of callbacks to execute when it becomes visible
*
* This is used to defer handling DOMFormHasPassword and onDOMInputPasswordAdded events when the
* containing document is hidden.
* When the document first becomes visible, any queued events will be handled as normal.
*
* @type {WeakMap}
*/
#visibleTasksByDocument = new WeakMap();
/**
* 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();
/**
* Set of fields where the user specifically requested password generation
* (from the context menu) even if we wouldn't offer it on this field by default.
*/
#fieldsWithPasswordGenerationForcedOn = new WeakSet();
static forWindow(window) {
return window.windowGlobalChild?.getActor("LoginManager");
}
receiveMessage(msg) {
switch (msg.name) {
case "PasswordManager:fillForm": {
this.fillForm({
loginFormOrigin: msg.data.loginFormOrigin,
loginsFound: lazy.LoginHelper.vanillaObjectsToLogins(msg.data.logins),
recipes: msg.data.recipes,
inputElementIdentifier: msg.data.inputElementIdentifier,
originMatches: msg.data.originMatches,
style: msg.data.style,
});
break;
}
case "PasswordManager:useGeneratedPassword": {
this.#onUseGeneratedPassword(msg.data.inputElementIdentifier);
break;
}
case "PasswordManager:repopulateAutocompletePopup": {
this.repopulateAutocompletePopup();
break;
}
case "PasswordManager:formIsPending": {
return this.#visibleTasksByDocument.has(this.document);
}
case "PasswordManager:formProcessed": {
this.notifyObserversOfFormProcessed(msg.data.formid);
break;
}
}
return undefined;
}
#onUseGeneratedPassword(inputElementIdentifier) {
let inputElement = lazy.ContentDOMReference.resolve(inputElementIdentifier);
if (!inputElement) {
lazy.log("Could not resolve inputElementIdentifier to a living element.");
return;
}
if (inputElement != lazy.gFormFillService.focusedInput) {
lazy.log("Could not open popup on input that's no longer focused");
return;
}
this.#fieldsWithPasswordGenerationForcedOn.add(inputElement);
this.repopulateAutocompletePopup();
}
repopulateAutocompletePopup() {
// Clear the cache of previous autocomplete results to show new options.
lazy.gFormFillService.QueryInterface(Ci.nsIAutoCompleteInput);
lazy.gFormFillService.controller.resetInternalState();
lazy.gFormFillService.showPopup();
}
shouldIgnoreLoginManagerEvent(event) {
let nodePrincipal = event.target.nodePrincipal;
// If we have a system or null principal then prevent any more password manager code from running and
// incorrectly using the document `location`. Also skip password manager for about: pages.
return (
nodePrincipal.isSystemPrincipal ||
nodePrincipal.isNullPrincipal ||
nodePrincipal.schemeIs("about")
);
}
handleEvent(event) {
if (
AppConstants.platform == "android" &&
Services.prefs.getBoolPref("reftest.remote", false)
) {
// XXX known incompatibility between reftest harness and form-fill. Is this still needed?
return;
}
if (this.shouldIgnoreLoginManagerEvent(event)) {
return;
}
switch (event.type) {
case "DOMDocFetchSuccess": {
this.#onDOMDocFetchSuccess(event);
break;
}
case "DOMFormBeforeSubmit": {
this.#onDOMFormBeforeSubmit(event);
break;
}
case "DOMFormHasPassword": {
this.#onDOMFormHasPassword(event);
let formLike = lazy.LoginFormFactory.createFromForm(
event.originalTarget
);
lazy.InsecurePasswordUtils.reportInsecurePasswords(formLike);
break;
}
case "DOMFormHasPossibleUsername": {
this.#onDOMFormHasPossibleUsername(event);
break;
}
case "DOMFormRemoved":
case "DOMInputPasswordRemoved": {
this.#onDOMFormRemoved(event);
break;
}
case "DOMInputPasswordAdded": {
this.#onDOMInputPasswordAdded(event, this.document.defaultView);
let formLike = lazy.LoginFormFactory.createFromField(
event.originalTarget
);
lazy.InsecurePasswordUtils.reportInsecurePasswords(formLike);
break;
}
}
}
notifyObserversOfFormProcessed(formid) {
Services.obs.notifyObservers(this, "passwordmgr-processed-form", formid);
}
/**
* Get relevant logins and recipes from the parent
*
* @param {HTMLFormElement} form - form to get login data for
* @param {Object} options
* @param {boolean} options.guid - guid of a login to retrieve
* @param {boolean} options.showPrimaryPassword - whether to show a primary password prompt
*/
_getLoginDataFromParent(form, options) {
let actionOrigin = lazy.LoginHelper.getFormActionOrigin(form);
let messageData = { actionOrigin, options };
let resultPromise = this.sendQuery(
"PasswordManager:findLogins",
messageData
);
return resultPromise.then(result => {
return {
form,
importable: result.importable,
loginsFound: lazy.LoginHelper.vanillaObjectsToLogins(result.logins),
recipes: result.recipes,
};
});
}
setupProgressListener(window) {
if (!lazy.LoginHelper.formlessCaptureEnabled) {
return;
}
// Get the highest accessible docshell and attach the progress listener to that.
let docShell;
for (
let browsingContext = BrowsingContext.getFromWindow(window);
browsingContext?.docShell;
browsingContext = browsingContext.parent
) {
docShell = browsingContext.docShell;
}
try {
let webProgress = docShell
.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
}
}
/**
* This method sets up form removal listener for form and password fields that
* users have interacted with.
*/
#onDOMDocFetchSuccess(event) {
let document = event.target;
let docState = this.stateForDocument(document);
let weakModificationsRootElements = ChromeUtils.nondeterministicGetWeakMapKeys(
docState.fieldModificationsByRootElement
);
lazy.log(
"onDOMDocFetchSuccess: modificationsByRootElement approx size:",
weakModificationsRootElements.length,
"document:",
document
);
// Start to listen to form/password removed event after receiving a fetch/xhr
// complete event.
document.setNotifyFormOrPasswordRemoved(true);
this.docShell.chromeEventHandler.addEventListener(
"DOMFormRemoved",
this,
true
);
this.docShell.chromeEventHandler.addEventListener(
"DOMInputPasswordRemoved",
this,
true
);
for (let rootElement of weakModificationsRootElements) {
if (HTMLFormElement.isInstance(rootElement)) {
// If we create formLike when it is removed, we might not have the
// right elements at that point, so create formLike object now.
let formLike = lazy.LoginFormFactory.createFromForm(rootElement);
docState.formLikeByObservedNode.set(rootElement, formLike);
}
}
let weakFormlessModifiedPasswordFields = ChromeUtils.nondeterministicGetWeakSetKeys(
docState.formlessModifiedPasswordFields
);
lazy.log(
"onDOMDocFetchSuccess: formlessModifiedPasswordFields approx size:",
weakFormlessModifiedPasswordFields.length,
"document:",
document
);
for (let passwordField of weakFormlessModifiedPasswordFields) {
let formLike = lazy.LoginFormFactory.createFromField(passwordField);
// force elements lazy getter being called.
if (formLike.elements.length) {
docState.formLikeByObservedNode.set(passwordField, formLike);
}
}
// Observers have been setted up, removed the listener.
document.setNotifyFetchSuccess(false);
}
/*
* Trigger capture when a form/formless password is removed from DOM.
* This method is used to capture logins for cases where form submit events
* are not used.
*
* The heuristic works as follow:
* 1. Set up 'DOMDocFetchSuccess' event listener when users have interacted
* with a form (by calling setNotifyFetchSuccess)
* 2. After receiving `DOMDocFetchSuccess`, set up form removal event listener
* (see onDOMDocFetchSuccess)
* 3. When a form is removed, onDOMFormRemoved triggers the login capture
* code.
*/
#onDOMFormRemoved(event) {
let document = event.composedTarget.ownerDocument;
let docState = this.stateForDocument(document);
let formLike = docState.formLikeByObservedNode.get(event.target);
if (!formLike) {
return;
}
lazy.log("form is removed");
this._onFormSubmit(formLike, SUBMIT_FORM_IS_REMOVED);
docState.formLikeByObservedNode.delete(event.target);
let weakObserveredNodes = ChromeUtils.nondeterministicGetWeakMapKeys(
docState.formLikeByObservedNode
);
if (!weakObserveredNodes.length) {
document.setNotifyFormOrPasswordRemoved(false);
this.docShell.chromeEventHandler.removeEventListener(
"DOMFormRemoved",
this
);
this.docShell.chromeEventHandler.removeEventListener(
"DOMInputPasswordRemoved",
this
);
}
}
#onDOMFormBeforeSubmit(event) {
if (!event.isTrusted) {
return;
}
// We're invoked before the content's |submit| event handlers, so we
// can grab form data before it might be modified (see bug 257781).
lazy.log("notified before form submission");
let formLike = lazy.LoginFormFactory.createFromForm(event.target);
this._onFormSubmit(formLike, SUBMIT_FORM_SUBMIT);
}
onDocumentVisibilityChange(event) {
if (!event.isTrusted) {
return;
}
let document = event.target;
let onVisibleTasks = this.#visibleTasksByDocument.get(document);
if (!onVisibleTasks) {
return;
}
for (let task of onVisibleTasks) {
lazy.log("onDocumentVisibilityChange, executing queued task");
task();
}
this.#visibleTasksByDocument.delete(document);
}
_deferHandlingEventUntilDocumentVisible(event, document, fn) {
lazy.log(
`document.visibilityState: ${document.visibilityState}, defer handling ${event.type}`
);
let onVisibleTasks = this.#visibleTasksByDocument.get(document);
if (!onVisibleTasks) {
lazy.log(
`deferHandling, first queued event, register the visibilitychange handler`
);
onVisibleTasks = [];
this.#visibleTasksByDocument.set(document, onVisibleTasks);
document.addEventListener(
"visibilitychange",
event => {
this.onDocumentVisibilityChange(event);
},
{ once: true }
);
}
onVisibleTasks.push(fn);
}
#getIsPrimaryPasswordSet() {
return Services.cpmm.sharedData.get("isPrimaryPasswordSet");
}
#onDOMFormHasPassword(event) {
if (!event.isTrusted) {
return;
}
const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet();
let document = event.target.ownerDocument;
// don't attempt to defer handling when a primary password is set
// Showing the MP modal as soon as possible minimizes its interference with tab interactions
// See bug 1539091 and bug 1538460.
lazy.log(
"onDOMFormHasPassword, visibilityState:",
document.visibilityState,
"isPrimaryPasswordSet:",
isPrimaryPasswordSet
);
if (document.visibilityState == "visible" || isPrimaryPasswordSet) {
this._processDOMFormHasPasswordEvent(event);
} else {
// wait until the document becomes visible before handling this event
this._deferHandlingEventUntilDocumentVisible(event, document, () => {
this._processDOMFormHasPasswordEvent(event);
});
}
}
_processDOMFormHasPasswordEvent(event) {
let form = event.target;
let formLike = lazy.LoginFormFactory.createFromForm(form);
lazy.log("_processDOMFormHasPasswordEvent:", form, formLike);
this._fetchLoginsFromParentAndFillForm(formLike);
}
#onDOMFormHasPossibleUsername(event) {
if (!event.isTrusted) {
return;
}
const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet();
let document = event.target.ownerDocument;
lazy.log(
"onDOMFormHasPossibleUsername, visibilityState:",
document.visibilityState,
"isPrimaryPasswordSet:",
isPrimaryPasswordSet
);
// For simplicity, the result of the telemetry is stacked. This means if a
// document receives two `DOMFormHasPossibleEvent`, we add one counter to both
// bucket 1 & 2.
let docState = this.stateForDocument(document);
Services.telemetry
.getHistogramById("PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC")
.add(++docState.numFormHasPossibleUsernameEvent);
// Infer whether a form is a username-only form is expensive, so we restrict the
// number of form looked up per document.
if (
docState.numFormHasPossibleUsernameEvent >
lazy.LoginHelper.usernameOnlyFormLookupThreshold
) {
return;
}
if (document.visibilityState == "visible" || isPrimaryPasswordSet) {
this._processDOMFormHasPossibleUsernameEvent(event);
} else {
// wait until the document becomes visible before handling this event
this._deferHandlingEventUntilDocumentVisible(event, document, () => {
this._processDOMFormHasPossibleUsernameEvent(event);
});
}
}
_processDOMFormHasPossibleUsernameEvent(event) {
let form = event.target;
let formLike = lazy.LoginFormFactory.createFromForm(form);
lazy.log("_processDOMFormHasPossibleUsernameEvent:", form, formLike);
// If the form contains a passoword field, `getUsernameFieldFromUsernameOnlyForm` returns
// null, so we don't trigger autofill for those forms here. In this function,
// we only care about username-only forms. For forms contain a password, they'll be handled
// in onDOMFormHasPassword.
// We specifically set the recipe to empty here to avoid loading site recipes during page loads.
// This is okay because if we end up finding a username-only form that should be ignore by
// the site recipe, the form will be skipped while autofilling later.
let docState = this.stateForDocument(form.ownerDocument);
let usernameField = docState.getUsernameFieldFromUsernameOnlyForm(form, {});
if (usernameField) {
// Autofill the username-only form.
lazy.log(
"_processDOMFormHasPossibleUsernameEvent: A username-only form is found"
);
this._fetchLoginsFromParentAndFillForm(formLike);
}
Services.telemetry
.getHistogramById("PWMGR_IS_USERNAME_ONLY_FORM")
.add(!!usernameField);
}
#onDOMInputPasswordAdded(event, window) {
if (!event.isTrusted) {
return;
}
this.setupProgressListener(window);
let pwField = event.originalTarget;
if (pwField.form) {
// Fill is handled by onDOMFormHasPassword which is already throttled.
return;
}
let document = pwField.ownerDocument;
const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet();
lazy.log(
"onDOMInputPasswordAdded, visibilityState:",
document.visibilityState,
"isPrimaryPasswordSet:",
isPrimaryPasswordSet
);
// don't attempt to defer handling when a primary password is set
// Showing the MP modal as soon as possible minimizes its interference with tab interactions
// See bug 1539091 and bug 1538460.
if (document.visibilityState == "visible" || isPrimaryPasswordSet) {
this._processDOMInputPasswordAddedEvent(event);
} else {
// wait until the document becomes visible before handling this event
this._deferHandlingEventUntilDocumentVisible(event, document, () => {
this._processDOMInputPasswordAddedEvent(event);
});
}
}
_processDOMInputPasswordAddedEvent(event) {
let pwField = event.originalTarget;
let formLike = lazy.LoginFormFactory.createFromField(pwField);
lazy.log(" _processDOMInputPasswordAddedEvent:", pwField, formLike);
let deferredTask = this.#deferredPasswordAddedTasksByRootElement.get(
formLike.rootElement
);
if (!deferredTask) {
lazy.log(
"Creating a DeferredTask to call _fetchLoginsFromParentAndFillForm soon"
);
lazy.LoginFormFactory.setForRootElement(formLike.rootElement, formLike);
deferredTask = new lazy.DeferredTask(
() => {
// Get the updated LoginForm instead of the one at the time of creating the DeferredTask via
// a closure since it could be stale since LoginForm.elements isn't live.
let formLike2 = lazy.LoginFormFactory.getForRootElement(
formLike.rootElement
);
lazy.log(
"Running deferred processing of onDOMInputPasswordAdded",
formLike2
);
this.#deferredPasswordAddedTasksByRootElement.delete(
formLike2.rootElement
);
this._fetchLoginsFromParentAndFillForm(formLike2);
},
PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS,
0
);
this.#deferredPasswordAddedTasksByRootElement.set(
formLike.rootElement,
deferredTask
);
}
let window = pwField.ownerGlobal;
if (deferredTask.isArmed) {
lazy.log("DeferredTask is already armed so just updating the LoginForm");
// We update the LoginForm so it (most important .elements) is fresh when the task eventually
// runs since changes to the elements could affect our field heuristics.
lazy.LoginFormFactory.setForRootElement(formLike.rootElement, formLike);
} else if (
["interactive", "complete"].includes(window.document.readyState)
) {
lazy.log(
"Arming the DeferredTask we just created since document.readyState == 'interactive' or 'complete'"
);
deferredTask.arm();
} else {
window.addEventListener(
"DOMContentLoaded",
function() {
lazy.log(
"Arming the onDOMInputPasswordAdded DeferredTask due to DOMContentLoaded"
);
deferredTask.arm();
},
{ once: true }
);
}
}
/**
* Fetch logins from the parent for a given form and then attempt to fill it.
*
* @param {LoginForm} form to fetch the logins for then try autofill.
*/
_fetchLoginsFromParentAndFillForm(form) {
if (!lazy.LoginHelper.enabled) {
return;
}
// set up input event listeners so we know if the user has interacted with these fields