diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/passwordmgr/LoginRecipes.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/LoginRecipes.sys.mjs')
-rw-r--r-- | toolkit/components/passwordmgr/LoginRecipes.sys.mjs | 381 |
1 files changed, 381 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginRecipes.sys.mjs b/toolkit/components/passwordmgr/LoginRecipes.sys.mjs new file mode 100644 index 0000000000..66d7bf80bd --- /dev/null +++ b/toolkit/components/passwordmgr/LoginRecipes.sys.mjs @@ -0,0 +1,381 @@ +/* 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/. */ + +const REQUIRED_KEYS = ["hosts"]; +const OPTIONAL_KEYS = [ + "description", + "notPasswordSelector", + "notUsernameSelector", + "passwordSelector", + "pathRegex", + "usernameSelector", + "schema", + "id", + "last_modified", +]; +const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => + lazy.LoginHelper.createLogger("LoginRecipes") +); + +/** + * Create an instance of the object to manage recipes in the parent process. + * Consumers should wait until {@link initializationPromise} resolves before + * calling methods on the object. + * + * @constructor + * @param {String} [aOptions.defaults=null] the URI to load the recipes from. + * If it's null, nothing is loaded. + * + */ +export function LoginRecipesParent(aOptions = { defaults: null }) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw new Error( + "LoginRecipesParent should only be used from the main process" + ); + } + this._defaults = aOptions.defaults; + this.reset(); +} + +LoginRecipesParent.prototype = { + /** + * Promise resolved with an instance of itself when the module is ready. + * + * @type {Promise} + */ + initializationPromise: null, + + /** + * @type {bool} Whether default recipes were loaded at construction time. + */ + _defaults: null, + + /** + * @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes. + * e.g. "example.com:8080" => Set({...}) + */ + _recipesByHost: null, + + /** + * @type {Object} Instance of Remote Settings client that has access to the + * "password-recipes" collection + */ + _rsClient: null, + + /** + * @param {Object} aRecipes an object containing recipes to load for use. The object + * should be compatible with JSON (e.g. no RegExp). + * @return {Promise} resolving when the recipes are loaded + */ + load(aRecipes) { + let recipeErrors = 0; + for (let rawRecipe of aRecipes.siteRecipes) { + try { + rawRecipe.pathRegex = rawRecipe.pathRegex + ? new RegExp(rawRecipe.pathRegex) + : undefined; + this.add(rawRecipe); + } catch (e) { + recipeErrors++; + lazy.log.error("Error loading recipe.", rawRecipe, e); + } + } + if (recipeErrors) { + return Promise.reject(`There were ${recipeErrors} recipe error(s)`); + } + return Promise.resolve(); + }, + + /** + * Reset the set of recipes to the ones from the time of construction. + */ + reset() { + lazy.log.debug("Resetting recipes with defaults:", this._defaults); + this._recipesByHost = new Map(); + if (this._defaults) { + let initPromise; + /** + * Both branches rely on a JSON dump of the Remote Settings collection, packaged both in Desktop and Android. + * The «legacy» mode will read the dump directly from the packaged resources. + * With Remote Settings, the dump is used to initialize the local database without network, + * and the list of password recipes can be refreshed without restarting and without software update. + */ + if (lazy.LoginHelper.remoteRecipesEnabled) { + if (!this._rsClient) { + this._rsClient = lazy.RemoteSettings( + lazy.LoginHelper.remoteRecipesCollection + ); + // Set up sync observer to update local recipes from Remote Settings recipes + this._rsClient.on("sync", event => this.onRemoteSettingsSync(event)); + } + initPromise = this._rsClient.get(); + } else if (this._defaults.startsWith("resource://")) { + initPromise = fetch(this._defaults) + .then(resp => resp.json()) + .then(({ data }) => data); + } else { + lazy.log.error( + "Invalid recipe path found, setting empty recipes list!" + ); + initPromise = new Promise(() => []); + } + this.initializationPromise = initPromise.then(async siteRecipes => { + Services.ppmm.broadcastAsyncMessage("clearRecipeCache"); + await this.load({ siteRecipes }); + return this; + }); + } else { + this.initializationPromise = Promise.resolve(this); + } + }, + + /** + * Validate the recipe is sane and then add it to the set of recipes. + * + * @param {Object} recipe + */ + add(recipe) { + let recipeKeys = Object.keys(recipe); + let unknownKeys = recipeKeys.filter(key => !SUPPORTED_KEYS.includes(key)); + if (unknownKeys.length) { + throw new Error( + "The following recipe keys aren't supported: " + unknownKeys.join(", ") + ); + } + + let missingRequiredKeys = REQUIRED_KEYS.filter( + key => !recipeKeys.includes(key) + ); + if (missingRequiredKeys.length) { + 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"); + } + + const OPTIONAL_STRING_PROPS = [ + "description", + "passwordSelector", + "usernameSelector", + ]; + for (let prop of OPTIONAL_STRING_PROPS) { + if (recipe[prop] && typeof recipe[prop] != "string") { + throw new Error(`'${prop}' 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; + }, + + /** + * Handles the Remote Settings sync event for the "password-recipes" collection. + * + * @param {Object} aEvent + * @param {Array} event.current Records in the "password-recipes" collection after the sync event + * @param {Array} event.created Records that were created with this particular sync + * @param {Array} event.updated Records that were updated with this particular sync + * @param {Array} event.deleted Records that were deleted with this particular sync + */ + onRemoteSettingsSync(aEvent) { + this._recipesByHost = new Map(); + let { + data: { current }, + } = aEvent; + let recipes = { + siteRecipes: current, + }; + Services.ppmm.broadcastAsyncMessage("clearRecipeCache"); + this.load(recipes); + }, +}; + +export const LoginRecipesContent = { + _recipeCache: new WeakMap(), + + _clearRecipeCache() { + lazy.log.debug("Clearing recipe cache."); + this._recipeCache = new WeakMap(); + }, + + /** + * Locally caches recipes for a given host. + * + * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com) + * @param {Object} win - the window of the host + * @param {Set} recipes - recipes that apply to the host + */ + cacheRecipes(aHost, win, recipes) { + let recipeMap = this._recipeCache.get(win); + + if (!recipeMap) { + recipeMap = new Map(); + this._recipeCache.set(win, recipeMap); + } + + recipeMap.set(aHost, recipes); + }, + + /** + * Tries to fetch recipes for a given host, using a local cache if possible. + * Otherwise, the recipes are cached for later use. + * + * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com) + * @param {Object} win - the window of the host + * @return {Set} of recipes that apply to the host + */ + getRecipes(aHost, win) { + let recipes; + const recipeMap = this._recipeCache.get(win); + + if (recipeMap) { + recipes = recipeMap.get(aHost); + + if (recipes) { + return recipes; + } + } + + if (!Cu.isInAutomation) { + // this is a blocking call we expect in tests and rarely expect in + // production, for example when Remote Settings are updated. + lazy.log.warn(`Falling back to a synchronous message for: ${aHost}.`); + } + recipes = Services.cpmm.sendSyncMessage("PasswordManager:findRecipes", { + formOrigin: aHost, + })[0]; + this.cacheRecipes(aHost, win, recipes); + + return recipes; + }, + + /** + * @param {Set} aRecipes - Possible recipes that could apply to the form + * @param {FormLike} aForm - We use a form instead of just a URL so we can later apply + * tests to the page contents. + * @return {Set} a subset of recipes that apply to the form with the order preserved + */ + _filterRecipesForForm(aRecipes, aForm) { + let formDocURL = aForm.ownerDocument.location; + let hostRecipes = aRecipes; + let recipes = new Set(); + if (!hostRecipes) { + return recipes; + } + + for (let hostRecipe of hostRecipes) { + if ( + hostRecipe.pathRegex && + !hostRecipe.pathRegex.test(formDocURL.pathname) + ) { + continue; + } + recipes.add(hostRecipe); + } + + return recipes; + }, + + /** + * Given a set of recipes that apply to the host, choose the one most applicable for + * overriding login fields in the form. + * + * @param {Set} aRecipes The set of recipes to consider for the form + * @param {FormLike} aForm The form where login fields exist. + * @return {Object} The recipe that is most applicable for the form. + */ + getFieldOverrides(aRecipes, aForm) { + let recipes = this._filterRecipesForForm(aRecipes, aForm); + lazy.log.debug(`Filtered recipes size: ${recipes.size}.`); + if (!recipes.size) { + return null; + } + + let chosenRecipe = null; + // Find the first (most-specific recipe that involves field overrides). + for (let recipe of recipes) { + if ( + !recipe.usernameSelector && + !recipe.passwordSelector && + !recipe.notUsernameSelector && + !recipe.notPasswordSelector + ) { + continue; + } + + chosenRecipe = recipe; + break; + } + + return chosenRecipe; + }, + + /** + * @param {HTMLElement} aParent the element to query for the selector from. + * @param {CSSSelector} aSelector the CSS selector to query for the login field. + * @return {HTMLElement|null} + */ + queryLoginField(aParent, aSelector) { + if (!aSelector) { + return null; + } + let field = aParent.ownerDocument.querySelector(aSelector); + if (!field) { + lazy.log.debug(`Login field selector wasn't matched: ${aSelector}.`); + return null; + } + // ownerGlobal doesn't exist in content privileged windows. + if ( + // eslint-disable-next-line mozilla/use-ownerGlobal + !aParent.ownerDocument.defaultView.HTMLInputElement.isInstance(field) + ) { + lazy.log.warn( + `Login field with selector ${aSelector} isn't an <input> so ignoring it.` + ); + return null; + } + return field; + }, +}; |