--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -1,21 +1,21 @@
/* vim: set ts=2 sts=2 sw=2 et tw=80: */
/* 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";
-const Cu = Components.utils;
-const Ci = Components.interfaces;
-const Cc = Components.classes;
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+Cu.importGlobalProperties(["URL"]);
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UserAutoCompleteResult",
"resource://gre/modules/LoginManagerContent.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AutoCompleteE10S",
"resource://gre/modules/AutoCompleteE10S.jsm");
this.EXPORTED_SYMBOLS = [ "LoginManagerParent" ];
@@ -60,28 +60,34 @@ prefChanged();
var LoginManagerParent = {
init: function() {
let mm = Cc["@mozilla.org/globalmessagemanager;1"]
.getService(Ci.nsIMessageListenerManager);
mm.addMessageListener("RemoteLogins:findLogins", this);
mm.addMessageListener("RemoteLogins:onFormSubmit", this);
mm.addMessageListener("RemoteLogins:autoCompleteLogins", this);
+
+ XPCOMUtils.defineLazyGetter(this, "recipeParentPromise", () => {
+ const { LoginRecipesParent } = Cu.import("resource://gre/modules/LoginRecipes.jsm", {});
+ let parent = new LoginRecipesParent();
+ return parent.init();
+ });
},
receiveMessage: function (msg) {
let data = msg.data;
switch (msg.name) {
case "RemoteLogins:findLogins": {
// TODO Verify msg.target's principals against the formOrigin?
- this.findLogins(data.options.showMasterPassword,
- data.formOrigin,
- data.actionOrigin,
- data.requestId,
- msg.target.messageManager);
+ this.sendLoginDataToChild(data.options.showMasterPassword,
+ data.formOrigin,
+ data.actionOrigin,
+ data.requestId,
+ msg.target.messageManager);
break;
}
case "RemoteLogins:onFormSubmit": {
// TODO Verify msg.target's principals against the formOrigin?
this.onFormSubmit(data.hostname,
data.formSubmitURL,
data.usernameField,
@@ -94,29 +100,45 @@ var LoginManagerParent = {
case "RemoteLogins:autoCompleteLogins": {
this.doAutocompleteSearch(data, msg.target);
break;
}
}
},
- findLogins: function(showMasterPassword, formOrigin, actionOrigin,
- requestId, target) {
+ /**
+ * Send relevant data (e.g. logins and recipes) to the child process (LoginManagerContent).
+ */
+ sendLoginDataToChild: Task.async(function*(showMasterPassword, formOrigin, actionOrigin,
+ requestId, target) {
+ let recipes = [];
+ if (formOrigin) {
+ let formHost = (new URL(formOrigin)).host;
+ let recipeManager = yield this.recipeParentPromise;
+ recipes = [...recipeManager.getRecipesForHost(formHost)];
+ }
+
if (!showMasterPassword && !Services.logins.isLoggedIn) {
- target.sendAsyncMessage("RemoteLogins:loginsFound",
- { requestId: requestId, logins: [] });
+ target.sendAsyncMessage("RemoteLogins:loginsFound", {
+ requestId: requestId,
+ logins: [],
+ recipes,
+ });
return;
}
let allLoginsCount = Services.logins.countLogins(formOrigin, "", null);
// If there are no logins for this site, bail out now.
if (!allLoginsCount) {
- target.sendAsyncMessage("RemoteLogins:loginsFound",
- { requestId: requestId, logins: [] });
+ target.sendAsyncMessage("RemoteLogins:loginsFound", {
+ requestId: requestId,
+ logins: [],
+ recipes,
+ });
return;
}
// If we're currently displaying a master password prompt, defer
// processing this form until the user handles the prompt.
if (Services.logins.uiBusy) {
log("deferring onFormPassword for", formOrigin);
let self = this;
@@ -125,23 +147,26 @@ var LoginManagerParent = {
Ci.nsISupportsWeakReference]),
observe: function (subject, topic, data) {
log("Got deferred onFormPassword notification:", topic);
// Only run observer once.
Services.obs.removeObserver(this, "passwordmgr-crypto-login");
Services.obs.removeObserver(this, "passwordmgr-crypto-loginCanceled");
if (topic == "passwordmgr-crypto-loginCanceled") {
- target.sendAsyncMessage("RemoteLogins:loginsFound",
- { requestId: requestId, logins: [] });
+ target.sendAsyncMessage("RemoteLogins:loginsFound", {
+ requestId: requestId,
+ logins: [],
+ recipes,
+ });
return;
}
- self.findLogins(showMasterPassword, formOrigin, actionOrigin,
- requestId, target);
+ self.sendLoginDataToChild(showMasterPassword, formOrigin, actionOrigin,
+ requestId, target);
},
};
// Possible leak: it's possible that neither of these notifications
// will fire, and if that happens, we'll leak the observer (and
// never return). We should guarantee that at least one of these
// will fire.
// See bug XXX.
@@ -152,28 +177,29 @@ var LoginManagerParent = {
var logins = Services.logins.findLogins({}, formOrigin, actionOrigin, null);
// Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
// doesn't support structured cloning.
var jsLogins = JSON.parse(JSON.stringify(logins));
target.sendAsyncMessage("RemoteLogins:loginsFound", {
requestId: requestId,
logins: jsLogins,
+ recipes,
});
const PWMGR_FORM_ACTION_EFFECT = Services.telemetry.getHistogramById("PWMGR_FORM_ACTION_EFFECT");
if (logins.length == 0) {
PWMGR_FORM_ACTION_EFFECT.add(2);
} else if (logins.length == allLoginsCount) {
PWMGR_FORM_ACTION_EFFECT.add(0);
} else {
// logins.length < allLoginsCount
PWMGR_FORM_ACTION_EFFECT.add(1);
}
- },
+ }),
doAutocompleteSearch: function({ formOrigin, actionOrigin,
searchString, previousResult,
rect, requestId, remote }, target) {
// Note: previousResult is a regular object, not an
// nsIAutoCompleteResult.
var result;
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginRecipes.jsm
@@ -0,0 +1,131 @@
+/* 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 = ["LoginRecipesContent", "LoginRecipesParent"];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+const REQUIRED_KEYS = ["hosts"];
+const OPTIONAL_KEYS = ["description", "pathRegex"];
+const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS);
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "log", () => LoginHelper.getLogger("LoginRecipes"));
+
+function LoginRecipesParent() {
+ if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
+ throw new Error("LoginRecipesParent should only be used on the main process");
+ }
+
+ this._recipesByHost = new Map();
+}
+
+LoginRecipesParent.prototype = {
+
+ _initializationPromise: null,
+
+ /**
+ * @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes.
+ * e.g. "example.com:8080" => Set({...})
+ */
+ _recipesByHost: null,
+
+ /**
+ * @return {Promise} resolved with the module when the module is ready.
+ */
+ init() {
+ if (this._initializationPromise) {
+ return this._initializationPromise;
+ }
+
+ return this._initializationPromise = Promise.resolve(this);
+ },
+
+ /**
+ * @param {Object} aRecipes - an object containing recipes to load for use
+ * @return {Promise} resolving when the recipes are loaded
+ */
+ load(aRecipes) {
+ for (let rawRecipe of aRecipes.siteRecipes) {
+ try {
+ let recipe = {
+ description: rawRecipe.description,
+ hosts: rawRecipe.hosts,
+ };
+ recipe.pathRegex = rawRecipe.pathRegex ? new RegExp(rawRecipe.pathRegex) : undefined;
+ this.add(recipe);
+ } catch (ex) {
+ log.error("Error loading recipe", rawRecipe, ex);
+ }
+ }
+
+ return Promise.resolve();
+ },
+
+ /**
+ * Validate the recipe is sane and then add it to the set of recipes.
+ *
+ * @param {Object} recipe
+ */
+ add(recipe) {
+ log.debug("Adding recipe:", recipe);
+ let recipeKeys = Object.keys(recipe);
+ let unknownKeys = recipeKeys.filter(key => SUPPORTED_KEYS.indexOf(key) == -1);
+ if (unknownKeys.length > 0) {
+ throw new Error("The following recipe keys aren't supported: " + unknownKeys.join(", "));
+ }
+
+ let missingRequiredKeys = REQUIRED_KEYS.filter(key => recipeKeys.indexOf(key) == -1);
+ if (missingRequiredKeys.length > 0) {
+ throw new Error("The following required recipe keys are missing: " + missingRequiredKeys.join(", "));
+ }
+
+ if (!Array.isArray(recipe.hosts)) {
+ throw new Error("'hosts' must be a array");
+ }
+
+ if (!recipe.hosts.length) {
+ throw new Error("'hosts' must be a non-empty array");
+ }
+
+ if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") {
+ throw new Error("'pathRegex' must be a regular expression");
+ }
+
+ if (recipe.description && typeof(recipe.description) != "string") {
+ throw new Error("'description' must be a string");
+ }
+
+ // Add the recipe to the map for each host
+ for (let host of recipe.hosts) {
+ if (!this._recipesByHost.has(host)) {
+ this._recipesByHost.set(host, new Set());
+ }
+ this._recipesByHost.get(host).add(recipe);
+ }
+ },
+
+ /**
+ * Currently only exact host matches are returned but this will eventually handle parent domains.
+ *
+ * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
+ * @return {Set} of recipes that apply to the host ordered by host priority
+ */
+ getRecipesForHost(aHost) {
+ let hostRecipes = this._recipesByHost.get(aHost);
+ if (!hostRecipes) {
+ return new Set();
+ }
+
+ return hostRecipes;
+ },
+};
--- a/toolkit/components/passwordmgr/moz.build
+++ b/toolkit/components/passwordmgr/moz.build
@@ -31,16 +31,17 @@ EXTRA_PP_COMPONENTS += [
'passwordmgr.manifest',
]
EXTRA_JS_MODULES += [
'InsecurePasswordUtils.jsm',
'LoginHelper.jsm',
'LoginManagerContent.jsm',
'LoginManagerParent.jsm',
+ 'LoginRecipes.jsm',
]
if CONFIG['OS_TARGET'] == 'Android':
EXTRA_COMPONENTS += [
'storage-mozStorage.js',
]
else:
EXTRA_COMPONENTS += [
--- a/toolkit/components/passwordmgr/test/unit/head.js
+++ b/toolkit/components/passwordmgr/test/unit/head.js
@@ -11,16 +11,17 @@
////////////////////////////////////////////////////////////////////////////////
//// Globals
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/LoginRecipes.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
"resource://gre/modules/DownloadPaths.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
@@ -193,16 +194,22 @@ const LoginTest = {
* since January 1, 1970, 00:00:00 UTC, falls within 30 seconds of now.
*/
assertTimeIsAboutNow: function (aTimeMs)
{
do_check_true(Math.abs(aTimeMs - Date.now()) < 30000);
}
};
+const RecipeHelpers = {
+ initNewParent() {
+ return (new LoginRecipesParent()).init();
+ },
+};
+
////////////////////////////////////////////////////////////////////////////////
//// Predefined test data
/**
* This object contains functions that return new instances of nsILoginInfo for
* every call. The returned instances can be compared using their "equals" or
* "matches" methods, or modified for the needs of the specific test being run.
*
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_recipes.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests adding and retrieving LoginRecipes in the parent process.
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+add_task(function* test_init() {
+ let parent = new LoginRecipesParent();
+ let initPromise1 = parent.init();
+ let initPromise2 = parent.init();
+ Assert.strictEqual(initPromise1, initPromise2, "Check that the same promise is returned");
+
+ let recipesParent = yield initPromise1;
+ Assert.ok(recipesParent instanceof LoginRecipesParent, "Check init return value");
+ Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes");
+});
+
+add_task(function* test_get_missing_host() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ let exampleRecipes = recipesParent.getRecipesForHost("example.invalid");
+ Assert.strictEqual(exampleRecipes.size, 0, "Check recipe count for example.invalid");
+
+});
+
+add_task(function* test_add_get_simple_host() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes");
+ recipesParent.add({
+ hosts: ["example.com"],
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+});
+
+add_task(function* test_add_get_non_standard_port_host() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com:8080"],
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com:8080");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com:8080");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com:8080", "Check the one host");
+});
+
+add_task(function* test_add_multiple_hosts() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com", "foo.invalid"],
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 2,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 2, "Check that two hosts are present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the first host");
+ Assert.strictEqual(recipe.hosts[1], "foo.invalid", "Check the second host");
+
+ let fooRecipes = recipesParent.getRecipesForHost("foo.invalid");
+ Assert.strictEqual(fooRecipes.size, 1, "Check recipe count for foo.invalid");
+ let fooRecipe = [...fooRecipes][0];
+ Assert.strictEqual(fooRecipe, recipe, "Check that the recipe is shared");
+ Assert.strictEqual(typeof(fooRecipe), "object", "Check recipe type");
+ Assert.strictEqual(fooRecipe.hosts.length, 2, "Check that two hosts are present");
+ Assert.strictEqual(fooRecipe.hosts[0], "example.com", "Check the first host");
+ Assert.strictEqual(fooRecipe.hosts[1], "foo.invalid", "Check the second host");
+});
+
+add_task(function* test_add_pathRegex() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com"],
+ pathRegex: /^\/mypath\//,
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+ Assert.strictEqual(recipe.pathRegex.toString(), "/^\\/mypath\\//", "Check the pathRegex");
+});
+
+/* Begin checking errors with add */
+
+add_task(function* test_add_missing_prop() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({}), /required/, "Some properties are required");
+});
+
+add_task(function* test_add_unknown_prop() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ unknownProp: true,
+ }), /supported/, "Unknown properties should cause an error to help with typos");
+});
+
+add_task(function* test_add_invalid_hosts() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: 404,
+ }), /array/, "hosts should be an array");
+});
+
+add_task(function* test_add_empty_host_array() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: [],
+ }), /array/, "hosts should be a non-empty array");
+});
+
+add_task(function* test_add_pathRegex_non_regexp() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: ["example.com"],
+ pathRegex: "foo",
+ }), /regular expression/, "pathRegex should be a RegExp");
+});
+
+/* End checking errors with add */
--- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -18,10 +18,11 @@ skip-if = os != "android"
[test_disabled_hosts.js]
[test_legacy_empty_formSubmitURL.js]
[test_legacy_validation.js]
[test_logins_change.js]
[test_logins_decrypt_failure.js]
[test_logins_metainfo.js]
[test_logins_search.js]
[test_notifications.js]
+[test_recipes.js]
[test_storage.js]
[test_telemetry.js]