fune/toolkit/components/passwordmgr/LoginAutoComplete.jsm

762 lines
22 KiB
JavaScript

/* 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/. */
/**
* nsIAutoCompleteResult and nsILoginAutoCompleteSearch implementations for saved logins.
*/
"use strict";
const EXPORTED_SYMBOLS = ["LoginAutoComplete", "LoginAutoCompleteResult"];
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.jsm",
LoginFormFactory: "resource://gre/modules/LoginFormFactory.jsm",
LoginHelper: "resource://gre/modules/LoginHelper.jsm",
LoginManagerChild: "resource://gre/modules/LoginManagerChild.jsm",
NewPasswordModel: "resource://gre/modules/NewPasswordModel.jsm",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"formFillController",
"@mozilla.org/satchel/form-fill-controller;1",
Ci.nsIFormFillController
);
XPCOMUtils.defineLazyGetter(lazy, "log", () => {
return lazy.LoginHelper.createLogger("LoginAutoComplete");
});
XPCOMUtils.defineLazyGetter(lazy, "passwordMgrBundle", () => {
return Services.strings.createBundle(
"chrome://passwordmgr/locale/passwordmgr.properties"
);
});
XPCOMUtils.defineLazyGetter(lazy, "dateAndTimeFormatter", () => {
return new Services.intl.DateTimeFormat(undefined, {
dateStyle: "medium",
});
});
function loginSort(formHostPort, a, b) {
let maybeHostPortA = lazy.LoginHelper.maybeGetHostPortForURL(a.origin);
let maybeHostPortB = lazy.LoginHelper.maybeGetHostPortForURL(b.origin);
if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) {
return -1;
}
if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) {
return 1;
}
if (a.httpRealm !== b.httpRealm) {
// Sort HTTP auth. logins after form logins for the same origin.
if (b.httpRealm === null) {
return 1;
}
if (a.httpRealm === null) {
return -1;
}
}
let userA = a.username.toLowerCase();
let 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;
}
function getLocalizedString(key, ...formatArgs) {
if (formatArgs.length) {
return lazy.passwordMgrBundle.formatStringFromName(key, formatArgs);
}
return lazy.passwordMgrBundle.GetStringFromName(key);
}
class AutocompleteItem {
constructor(style) {
this.comment = "";
this.style = style;
this.value = "";
}
removeFromStorage() {
/* Do nothing by default */
}
}
class InsecureLoginFormAutocompleteItem extends AutocompleteItem {
constructor() {
super("insecureWarning");
XPCOMUtils.defineLazyGetter(this, "label", () => {
let learnMoreString = getLocalizedString("insecureFieldWarningLearnMore");
return getLocalizedString(
"insecureFieldWarningDescription2",
learnMoreString
);
});
}
}
class LoginAutocompleteItem extends AutocompleteItem {
login;
#actor;
constructor(
login,
hasBeenTypePassword,
duplicateUsernames,
actor,
isOriginMatched
) {
super("loginWithOrigin");
this.login = login.QueryInterface(Ci.nsILoginMetaInfo);
this.#actor = actor;
let isDuplicateUsername =
login.username && duplicateUsernames.has(login.username);
XPCOMUtils.defineLazyGetter(this, "label", () => {
let username = login.username;
// If login is empty or duplicated we want to append a modification date to it.
if (!username || isDuplicateUsername) {
if (!username) {
username = getLocalizedString("noUsername");
}
let time = lazy.dateAndTimeFormatter.format(
new Date(login.timePasswordChanged)
);
username = getLocalizedString("loginHostAge", username, time);
}
return username;
});
XPCOMUtils.defineLazyGetter(this, "value", () => {
return hasBeenTypePassword ? login.password : login.username;
});
XPCOMUtils.defineLazyGetter(this, "comment", () => {
return JSON.stringify({
guid: login.guid,
login,
isDuplicateUsername,
isOriginMatched,
comment:
isOriginMatched && login.httpRealm === null
? getLocalizedString("displaySameOrigin")
: login.displayOrigin,
});
});
}
removeFromStorage() {
if (this.#actor) {
let vanilla = lazy.LoginHelper.loginToVanillaObject(this.login);
this.#actor.sendAsyncMessage("PasswordManager:removeLogin", {
login: vanilla,
});
} else {
Services.logins.removeLogin(this.login);
}
}
}
class GeneratedPasswordAutocompleteItem extends AutocompleteItem {
constructor(generatedPassword, willAutoSaveGeneratedPassword) {
super("generatedPassword");
XPCOMUtils.defineLazyGetter(this, "comment", () =>
JSON.stringify({
generatedPassword,
willAutoSaveGeneratedPassword,
})
);
this.value = generatedPassword;
XPCOMUtils.defineLazyGetter(this, "label", () =>
getLocalizedString("useASecurelyGeneratedPassword")
);
}
}
class ImportableLearnMoreAutocompleteItem extends AutocompleteItem {
constructor() {
super("importableLearnMore");
this.comment = JSON.stringify({
fillMessageName: "PasswordManager:OpenImportableLearnMore",
});
}
}
class ImportableLoginsAutocompleteItem extends AutocompleteItem {
#actor;
constructor(browserId, hostname, actor) {
super("importableLogins");
this.label = browserId;
this.comment = JSON.stringify({
hostname,
fillMessageName: "PasswordManager:HandleImportable",
fillMessageData: {
browserId,
},
});
this.#actor = actor;
// This is sent for every item (re)shown, but the parent will debounce to
// reduce the count by 1 total.
this.#actor.sendAsyncMessage(
"PasswordManager:decreaseSuggestImportCount",
1
);
}
removeFromStorage() {
this.#actor.sendAsyncMessage(
"PasswordManager:decreaseSuggestImportCount",
100
);
}
}
class LoginsFooterAutocompleteItem extends AutocompleteItem {
constructor(formHostname, telemetryEventData) {
super("loginsFooter");
XPCOMUtils.defineLazyGetter(this, "comment", () => {
// The comment field of `loginsFooter` results have many additional pieces of
// information for telemetry purposes. After bug 1555209, this information
// can be passed to the parent process outside of nsIAutoCompleteResult APIs
// so we won't need this hack.
return JSON.stringify({
telemetryEventData,
formHostname,
fillMessageName: "PasswordManager:OpenPreferences",
fillMessageData: {
entryPoint: "autocomplete",
},
});
});
XPCOMUtils.defineLazyGetter(this, "label", () => {
return getLocalizedString("viewSavedLogins.label");
});
}
}
// nsIAutoCompleteResult implementation
class LoginAutoCompleteResult {
#rows = [];
constructor(
aSearchString,
matchingLogins,
formOrigin,
{
generatedPassword,
willAutoSaveGeneratedPassword,
importable,
isSecure,
actor,
hasBeenTypePassword,
hostname,
telemetryEventData,
}
) {
let hidingFooterOnPWFieldAutoOpened = false;
const importableBrowsers =
importable?.state === "import" && importable?.browsers;
function isFooterEnabled() {
// We need to check LoginHelper.enabled here since the insecure warning should
// appear even if pwmgr is disabled but the footer should never appear in that case.
if (
!lazy.LoginHelper.showAutoCompleteFooter ||
!lazy.LoginHelper.enabled
) {
return false;
}
// Don't show the footer on non-empty password fields as it's not providing
// value and only adding noise since a password was already filled.
if (hasBeenTypePassword && aSearchString && !generatedPassword) {
lazy.log.debug("Hiding footer: non-empty password field");
return false;
}
if (
!importableBrowsers &&
!matchingLogins.length &&
!generatedPassword &&
hasBeenTypePassword &&
lazy.formFillController.passwordPopupAutomaticallyOpened
) {
hidingFooterOnPWFieldAutoOpened = true;
lazy.log.debug(
"Hiding footer: no logins and the popup was opened upon focus of the pw. field"
);
return false;
}
return true;
}
this.searchString = aSearchString;
// Insecure field warning comes first.
if (!isSecure) {
this.#rows.push(new InsecureLoginFormAutocompleteItem());
}
// Saved login items
let formHostPort = lazy.LoginHelper.maybeGetHostPortForURL(formOrigin);
let logins = matchingLogins.sort(loginSort.bind(null, formHostPort));
let duplicateUsernames = findDuplicates(matchingLogins);
for (let login of logins) {
let item = new LoginAutocompleteItem(
login,
hasBeenTypePassword,
duplicateUsernames,
actor,
lazy.LoginHelper.isOriginMatching(login.origin, formOrigin, {
schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
})
);
this.#rows.push(item);
}
// The footer comes last if it's enabled
if (isFooterEnabled()) {
if (generatedPassword) {
this.#rows.push(
new GeneratedPasswordAutocompleteItem(
generatedPassword,
willAutoSaveGeneratedPassword
)
);
}
// Suggest importing logins if there are none found.
if (!logins.length && importableBrowsers) {
this.#rows.push(
...importableBrowsers.map(
browserId =>
new ImportableLoginsAutocompleteItem(browserId, hostname, actor)
)
);
this.#rows.push(new ImportableLearnMoreAutocompleteItem());
}
this.#rows.push(
new LoginsFooterAutocompleteItem(hostname, telemetryEventData)
);
}
// Determine the result code and default index.
if (this.matchCount > 0) {
this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
this.defaultIndex = 0;
} else if (hidingFooterOnPWFieldAutoOpened) {
// We use a failure result so that the empty results aren't re-used for when
// the user tries to manually open the popup (we want the footer in that case).
this.searchResult = Ci.nsIAutoCompleteResult.RESULT_FAILURE;
this.defaultIndex = -1;
}
}
QueryInterface = ChromeUtils.generateQI([
"nsIAutoCompleteResult",
"nsISupportsWeakReference",
]);
/**
* Accessed via .wrappedJSObject
* @private
*/
get logins() {
return this.#rows
.filter(item => item instanceof LoginAutocompleteItem)
.map(item => item.login);
}
// 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 = "";
get matchCount() {
return this.#rows.length;
}
#throwOnBadIndex(index) {
if (index < 0 || index >= this.matchCount) {
throw new Error("Index out of range.");
}
}
getValueAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].value;
}
getLabelAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].label;
}
getCommentAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].comment;
}
getStyleAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].style;
}
getImageAt(index) {
this.#throwOnBadIndex(index);
return "";
}
getFinalCompleteValueAt(index) {
return this.getValueAt(index);
}
isRemovableAt(index) {
this.#throwOnBadIndex(index);
return true;
}
removeValueAt(index) {
this.#throwOnBadIndex(index);
let [removedItem] = this.#rows.splice(index, 1);
if (this.defaultIndex > this.#rows.length) {
this.defaultIndex--;
}
removedItem.removeFromStorage();
}
}
class LoginAutoComplete {
// HTMLInputElement to number, the element's new-password heuristic confidence score
#cachedNewPasswordScore = new WeakMap();
#autoCompleteLookupPromise = null;
classID = Components.ID("{2bdac17c-53f1-4896-a521-682ccdeef3a8}");
QueryInterface = ChromeUtils.generateQI(["nsILoginAutoCompleteSearch"]);
/**
* Yuck. This is called directly by satchel:
* nsFormFillController::StartSearch()
* [toolkit/components/satchel/nsFormFillController.cpp]
*
* We really ought to have a simple way for code to register an
* auto-complete provider, and not have satchel calling pwmgr directly.
*
* @param {string} aSearchString The value typed in the field.
* @param {nsIAutoCompleteResult} aPreviousResult
* @param {HTMLInputElement} aElement
* @param {nsIFormAutoCompleteObserver} aCallback
*/
startSearch(aSearchString, aPreviousResult, aElement, aCallback) {
let { isNullPrincipal } = aElement.nodePrincipal;
if (
aElement.nodePrincipal.schemeIs("about") ||
aElement.nodePrincipal.isSystemPrincipal
) {
// Don't show autocomplete results for about: pages.
// XXX: Don't we need to call the callback here?
return;
}
let searchStartTimeMS = Services.telemetry.msSystemNow();
// Show the insecure login warning in the passwords field on null principal documents.
// Avoid loading InsecurePasswordUtils.jsm in a sandboxed document (e.g. an ad. frame) if we
// already know it has a null principal and will therefore get the insecure autocomplete
// treatment.
// InsecurePasswordUtils doesn't handle the null principal case as not secure because we don't
// want the same treatment:
// * The web console warnings will be confusing (as they're primarily about http:) and not very
// useful if the developer intentionally sandboxed the document.
// * The site identity insecure field warning would require LoginManagerChild being loaded and
// listening to some of the DOM events we're ignoring in null principal documents. For memory
// reasons it's better to not load LMC at all for these sandboxed frames. Also, if the top-
// document is sandboxing a document, it probably doesn't want that sandboxed document to be
// able to affect the identity icon in the address bar by adding a password field.
let form = lazy.LoginFormFactory.createFromField(aElement);
let isSecure =
!isNullPrincipal && lazy.InsecurePasswordUtils.isFormSecure(form);
let { hasBeenTypePassword } = aElement;
let hostname = aElement.ownerDocument.documentURIObject.host;
let formOrigin = lazy.LoginHelper.getLoginOrigin(
aElement.ownerDocument.documentURI
);
let loginManagerActor = lazy.LoginManagerChild.forWindow(
aElement.ownerGlobal
);
let completeSearch = async autoCompleteLookupPromise => {
// Assign to the member synchronously before awaiting the Promise.
this.#autoCompleteLookupPromise = autoCompleteLookupPromise;
let {
generatedPassword,
importable,
logins,
willAutoSaveGeneratedPassword,
} = await autoCompleteLookupPromise;
// If the search was canceled before we got our
// results, don't bother reporting them.
// N.B. This check must occur after the `await` above for it to be
// effective.
if (this.#autoCompleteLookupPromise !== autoCompleteLookupPromise) {
lazy.log.debug("Ignoring result from previous search.");
return;
}
let telemetryEventData = {
acFieldName: aElement.getAutocompleteInfo().fieldName,
hadPrevious: !!aPreviousResult,
typeWasPassword: aElement.hasBeenTypePassword,
fieldType: aElement.type,
searchStartTimeMS,
stringLength: aSearchString.length,
};
this.#autoCompleteLookupPromise = null;
let results = new LoginAutoCompleteResult(
aSearchString,
logins,
formOrigin,
{
generatedPassword,
willAutoSaveGeneratedPassword,
importable,
actor: loginManagerActor,
isSecure,
hasBeenTypePassword,
hostname,
telemetryEventData,
}
);
aCallback.onSearchCompletion(results);
};
if (isNullPrincipal) {
// Don't search login storage when the field has a null principal as we don't want to fill
// logins for the `location` in this case.
completeSearch(Promise.resolve({ logins: [] }));
return;
}
if (
hasBeenTypePassword &&
aSearchString &&
!loginManagerActor.isPasswordGenerationForcedOn(aElement)
) {
// Return empty result on password fields with password already filled,
// unless password generation was forced.
completeSearch(Promise.resolve({ logins: [] }));
return;
}
if (!lazy.LoginHelper.enabled) {
completeSearch(Promise.resolve({ logins: [] }));
return;
}
let previousResult;
if (aPreviousResult) {
previousResult = {
searchString: aPreviousResult.searchString,
logins: lazy.LoginHelper.loginsToVanillaObjects(
aPreviousResult.wrappedJSObject.logins
),
};
} else {
previousResult = null;
}
let acLookupPromise = this.#requestAutoCompleteResultsFromParent({
searchString: aSearchString,
previousResult,
inputElement: aElement,
form,
hasBeenTypePassword,
});
completeSearch(acLookupPromise).catch(lazy.log.error.bind(lazy.log));
}
stopSearch() {
this.#autoCompleteLookupPromise = null;
}
async #requestAutoCompleteResultsFromParent({
searchString,
previousResult,
inputElement,
form,
hasBeenTypePassword,
}) {
let actionOrigin = lazy.LoginHelper.getFormActionOrigin(form);
let autocompleteInfo = inputElement.getAutocompleteInfo();
let loginManagerActor = lazy.LoginManagerChild.forWindow(
inputElement.ownerGlobal
);
let forcePasswordGeneration = false;
let isProbablyANewPasswordField = false;
if (hasBeenTypePassword) {
forcePasswordGeneration = loginManagerActor.isPasswordGenerationForcedOn(
inputElement
);
// Run the Fathom model only if the password field does not have the
// autocomplete="new-password" attribute.
isProbablyANewPasswordField =
autocompleteInfo.fieldName == "new-password" ||
this.isProbablyANewPasswordField(inputElement);
}
const messageData = {
actionOrigin,
searchString,
previousResult,
forcePasswordGeneration,
hasBeenTypePassword,
isProbablyANewPasswordField,
};
if (lazy.LoginHelper.showAutoCompleteFooter) {
gAutoCompleteListener.init();
}
lazy.log.debug("LoginAutoComplete search:", {
forcePasswordGeneration,
hasBeenTypePassword,
isProbablyANewPasswordField,
searchStringLength: searchString.length,
});
let result = await loginManagerActor.sendQuery(
"PasswordManager:autoCompleteLogins",
messageData
);
return {
generatedPassword: result.generatedPassword,
importable: result.importable,
logins: lazy.LoginHelper.vanillaObjectsToLogins(result.logins),
willAutoSaveGeneratedPassword: result.willAutoSaveGeneratedPassword,
};
}
isProbablyANewPasswordField(inputElement) {
const threshold = lazy.LoginHelper.generationConfidenceThreshold;
if (threshold == -1) {
// Fathom is disabled
return false;
}
let score = this.#cachedNewPasswordScore.get(inputElement);
if (score) {
return score >= threshold;
}
const { rules, type } = lazy.NewPasswordModel;
const results = rules.against(inputElement);
score = results.get(inputElement).scoreFor(type);
this.#cachedNewPasswordScore.set(inputElement, score);
return score >= threshold;
}
}
let gAutoCompleteListener = {
added: false,
fillRequestId: 0,
init() {
if (!this.added) {
Services.obs.addObserver(this, "autocomplete-will-enter-text");
this.added = true;
}
},
async observe(subject, topic, data) {
switch (topic) {
case "autocomplete-will-enter-text": {
await this.sendFillRequestToLoginManagerParent(subject, data);
break;
}
}
},
async sendFillRequestToLoginManagerParent(input, comment) {
if (!comment) {
return;
}
if (input != lazy.formFillController.controller.input) {
return;
}
const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}");
if (!fillMessageName) {
return;
}
this.fillRequestId++;
const fillRequestId = this.fillRequestId;
const child = lazy.LoginManagerChild.forWindow(
input.focusedInput.ownerGlobal
);
const value = await child.sendQuery(fillMessageName, fillMessageData ?? {});
// skip fill if another fill operation started during await
if (fillRequestId != this.fillRequestId) {
return;
}
if (typeof value !== "string") {
return;
}
// If LoginManagerParent returned a string to fill, we must do it here because
// nsAutoCompleteController.cpp already finished it's work before we finished await.
input.textValue = value;
input.selectTextRange(value.length, value.length);
},
};