diff --git a/mobile/android/installer/package-manifest.in b/mobile/android/installer/package-manifest.in index 692ba016b6f2..c30f426c2129 100644 --- a/mobile/android/installer/package-manifest.in +++ b/mobile/android/installer/package-manifest.in @@ -88,6 +88,7 @@ @BINPATH@/defaults/settings/blocklists/addons.json ; TODO bug 1639050: addons-bloomfilters should be used instead of addons.json @BINPATH@/defaults/settings/security-state/onecrl.json +@BINPATH@/defaults/settings/main/password-recipes.json ; [Components] @BINPATH@/components/components.manifest diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js index 7eae338d132f..f2fffba8b0d0 100644 --- a/modules/libpref/init/all.js +++ b/modules/libpref/init/all.js @@ -3750,7 +3750,9 @@ pref("signon.privateBrowsingCapture.enabled", true); pref("signon.storeWhenAutocompleteOff", true); pref("signon.userInputRequiredToCapture.enabled", true); pref("signon.debug", false); -pref("signon.recipes.path", "chrome://passwordmgr/content/recipes.json"); +pref("signon.recipes.path", "resource://app/defaults/settings/main/password-recipes.json"); +pref("signon.recipes.remoteRecipesEnabled", true); + pref("signon.schemeUpgrades", true); pref("signon.includeOtherSubdomainsInLookup", true); // This temporarily prevents the master password to reprompt for autocomplete. diff --git a/services/settings/dumps/main/moz.build b/services/settings/dumps/main/moz.build index 35e0bee898c4..509ef03fa373 100644 --- a/services/settings/dumps/main/moz.build +++ b/services/settings/dumps/main/moz.build @@ -7,6 +7,7 @@ FINAL_TARGET_FILES.defaults.settings.main += [ 'example.json', 'hijack-blocklists.json', 'language-dictionaries.json', + 'password-recipes.json', 'search-config.json', 'search-default-override-allowlist.json', 'sites-classification.json', diff --git a/services/settings/dumps/main/password-recipes.json b/services/settings/dumps/main/password-recipes.json new file mode 100644 index 000000000000..edf989dafb94 --- /dev/null +++ b/services/settings/dumps/main/password-recipes.json @@ -0,0 +1 @@ +{"data":[{"hosts":["www.dmv.ca.gov"],"schema":1600889016495,"description":"www.dmv.ca.gov: Checking account form field has type=password fields and gets login suggestions (Bug 1595339)","notPasswordSelector":"#bankRoutingNo, #validateBankRoutingNo, #bankAcctNo, #validateBankAcctNo","id":"85113859-1767-4fe4-bec3-826028b7cc91","last_modified":1600889167888},{"hosts":["mozilla.okta.com"],"schema":1599766903292,"description":"okta uses a hidden password field to disable filling","passwordSelector":"#pass-signin","id":"99bed888-5309-468f-9924-2a57bd5289ee","last_modified":1600889016443},{"hosts":["www.anthem.com"],"schema":1599839796360,"description":"anthem uses a hidden password and username field to disable filling","passwordSelector":"#LoginContent_txtLoginPass","id":"ef7f2f3f-04b9-49d0-a2b5-3e3766b41d19","last_modified":1600889016440},{"hosts":["www.discover.com"],"schema":1599839796979,"description":"An ephemeral password-shim field is incorrectly selected as the username field.","usernameSelector":"#login-account","id":"dc49fe14-1969-48ab-b98b-2055480e55a4","last_modified":1600889016436},{"hosts":["secure.tibia.com"],"schema":1599839797632,"pathRegex":"^\\/account\\/","description":"Tibia uses type=password for its username field and puts the email address before the password field during registration","passwordSelector":"#password1, input[name='loginpassword']","usernameSelector":"#accountname, input[name='loginname']","id":"8fdbd3ae-4f74-4ff6-9399-b1c4cb2a6421","last_modified":1600889016433},{"hosts":["www.facebook.com"],"schema":1599839798260,"description":"Username field will be incorrectly captured in the change password form (bug 1243722)","notUsernameSelector":"#password_strength","id":"1f76dae4-c8ad-4633-b9fa-3a2420cf7e5c","last_modified":1600889016430},{"hosts":["www.united.com"],"schema":1599839798880,"pathRegex":"^\\/travel\\/checkin\\/changefqtv.aspx","description":"United uses a useless password field plus one per frequent flyer number during checkin. Don't save any of them (Bug 1330810)","notPasswordSelector":"input[type='password']","id":"ed57feb9-a928-4f61-8947-c676bd95a7b0","last_modified":1600889016426},{"hosts":["buy.gogoinflight.com"],"schema":1599839799488,"description":"Gogo In-Flight uses a password field for credit card numbers on the same page as login","notPasswordSelector":"#cardNumber","id":"2de99a9f-9430-411a-94d0-dbb31910742e","last_modified":1600889016423},{"hosts":["mabanque.fortuneo.fr"],"schema":1599839800167,"pathRegex":"\\\\/identification\\\\.jsp$","description":"The Fortuneo bank uses a different form for each input, defeating the password manager (Bug 1433754)","usernameSelector":"input[name='LOGIN']","id":"aebc1ea5-ca41-4258-9594-9255bbcadf1e","last_modified":1600889016419},{"hosts":["www.benefits.ml.com"],"schema":1599839800780,"pathRegex":"^\\/login\\/login$","description":"Merrill's benefits website has six type=password fields which is over our threshold for filling (Bug 1538026)","passwordSelector":"#SFText_Password","usernameSelector":"#SFText_UserName","id":"00814138-7ede-4f56-8953-b6d1c99d5f26","last_modified":1600889016416}]} \ No newline at end of file diff --git a/toolkit/components/passwordmgr/LoginHelper.jsm b/toolkit/components/passwordmgr/LoginHelper.jsm index a0ddb9302721..0bed3efc056a 100644 --- a/toolkit/components/passwordmgr/LoginHelper.jsm +++ b/toolkit/components/passwordmgr/LoginHelper.jsm @@ -38,6 +38,8 @@ this.LoginHelper = { includeOtherSubdomainsInLookup: null, insecureAutofill: null, privateBrowsingCaptureEnabled: null, + remoteRecipesEnabled: null, + remoteRecipesCollection: "password-recipes", schemeUpgrades: null, showAutoCompleteFooter: null, showAutoCompleteImport: null, @@ -152,6 +154,9 @@ this.LoginHelper = { this.userInputRequiredToCapture = Services.prefs.getBoolPref( "signon.userInputRequiredToCapture.enabled" ); + this.remoteRecipesEnabled = Services.prefs.getBoolPref( + "signon.recipes.remoteRecipesEnabled" + ); }, createLogger(aLogPrefix) { diff --git a/toolkit/components/passwordmgr/LoginRecipes.jsm b/toolkit/components/passwordmgr/LoginRecipes.jsm index a2878d32c8b0..826e3a9b27a8 100644 --- a/toolkit/components/passwordmgr/LoginRecipes.jsm +++ b/toolkit/components/passwordmgr/LoginRecipes.jsm @@ -14,6 +14,9 @@ const OPTIONAL_KEYS = [ "passwordSelector", "pathRegex", "usernameSelector", + "schema", + "id", + "last_modified", ]; const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS); @@ -23,7 +26,7 @@ const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); -XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); +XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "fetch"]); ChromeUtils.defineModuleGetter( this, @@ -35,6 +38,10 @@ XPCOMUtils.defineLazyGetter(this, "log", () => LoginHelper.createLogger("LoginRecipes") ); +XPCOMUtils.defineLazyModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.js", +}); + /** * Create an instance of the object to manage recipes in the parent process. * Consumers should wait until {@link initializationPromise} resolves before @@ -74,6 +81,12 @@ LoginRecipesParent.prototype = { */ _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). @@ -92,11 +105,9 @@ LoginRecipesParent.prototype = { log.error("Error loading recipe", rawRecipe, ex); } } - if (recipeErrors) { return Promise.reject(`There were ${recipeErrors} recipe error(s)`); } - return Promise.resolve(); }, @@ -106,37 +117,34 @@ LoginRecipesParent.prototype = { reset() { log.debug("Resetting recipes with defaults:", this._defaults); this._recipesByHost = new Map(); - if (this._defaults) { - let channel = NetUtil.newChannel({ - uri: NetUtil.newURI(this._defaults, "UTF-8"), - loadUsingSystemPrincipal: true, - }); - channel.contentType = "application/json"; - - try { - this.initializationPromise = new Promise(function(resolve) { - NetUtil.asyncFetch(channel, function(stream, result) { - if (!Components.isSuccessCode(result)) { - throw new Error("Error fetching recipe file:" + result); - } - let count = stream.available(); - let data = NetUtil.readInputStreamToString(stream, count, { - charset: "UTF-8", - }); - resolve(JSON.parse(data)); - }); - }) - .then(recipes => { - Services.ppmm.broadcastAsyncMessage("clearRecipeCache"); - return this.load(recipes); - }) - .then(resolve => { - return this; - }); - } catch (e) { - throw new Error("Error reading recipe file:" + e); + 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 (LoginHelper.remoteRecipesEnabled) { + if (!this._rsClient) { + this._rsClient = RemoteSettings(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 { + 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); } @@ -213,6 +221,27 @@ LoginRecipesParent.prototype = { 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); + }, }; this.LoginRecipesContent = { diff --git a/toolkit/components/passwordmgr/content/recipes.json b/toolkit/components/passwordmgr/content/recipes.json deleted file mode 100644 index 9fe7a38aea6b..000000000000 --- a/toolkit/components/passwordmgr/content/recipes.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "siteRecipes": [ - { - "description": "okta uses a hidden password field to disable filling", - "hosts": ["mozilla.okta.com"], - "passwordSelector": "#pass-signin" - }, - { - "description": "anthem uses a hidden password and username field to disable filling", - "hosts": ["www.anthem.com"], - "passwordSelector": "#LoginContent_txtLoginPass" - }, - { - "description": "An ephemeral password-shim field is incorrectly selected as the username field.", - "hosts": ["www.discover.com"], - "usernameSelector": "#login-account" - }, - { - "description": "Tibia uses type=password for its username field and puts the email address before the password field during registration", - "hosts": ["secure.tibia.com"], - "usernameSelector": "#accountname, input[name='loginname']", - "passwordSelector": "#password1, input[name='loginpassword']", - "pathRegex": "^\/account\/" - }, - { - "description": "Username field will be incorrectly captured in the change password form (bug 1243722)", - "hosts": ["www.facebook.com"], - "notUsernameSelector": "#password_strength" - }, - { - "description": "United uses a useless password field plus one per frequent flyer number during checkin. Don't save any of them (Bug 1330810)", - "hosts": ["www.united.com"], - "notPasswordSelector": "input[type='password']", - "pathRegex": "^\/travel\/checkin\/changefqtv.aspx" - }, - { - "description": "Gogo In-Flight uses a password field for credit card numbers on the same page as login", - "hosts": ["buy.gogoinflight.com"], - "notPasswordSelector": "#cardNumber" - }, - { - "description": "The Fortuneo bank uses a different form for each input, defeating the password manager (Bug 1433754)", - "hosts": ["mabanque.fortuneo.fr"], - "pathRegex": "\\/identification\\.jsp$", - "usernameSelector": "input[name='LOGIN']" - }, - { - "description": "Merrill's benefits website has six type=password fields which is over our threshold for filling (Bug 1538026)", - "hosts": ["www.benefits.ml.com"], - "usernameSelector": "#SFText_UserName", - "passwordSelector": "#SFText_Password", - "pathRegex": "^\/login\/login$" - }, - { - "description": "Bug 1595339: Checking account type=password fields get login suggestions", - "hosts": ["www.dmv.ca.gov"], - "notPasswordSelector": "#bankRoutingNo, #validateBankRoutingNo, #bankAcctNo, #validateBankAcctNo" - } - ] -} diff --git a/toolkit/components/passwordmgr/jar.mn b/toolkit/components/passwordmgr/jar.mn index b3807dabb5f6..89b96a386984 100644 --- a/toolkit/components/passwordmgr/jar.mn +++ b/toolkit/components/passwordmgr/jar.mn @@ -4,4 +4,3 @@ toolkit.jar: % content passwordmgr %content/passwordmgr/ - content/passwordmgr/recipes.json (content/recipes.json) diff --git a/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js new file mode 100644 index 000000000000..4197865f09b2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests retrieving remote LoginRecipes in the parent process. + */ + +"use strict"; + +const { RemoteSettings } = ChromeUtils.import( + "resource://services-settings/remote-settings.js", + {} +); + +const REMOTE_SETTINGS_COLLECTION = "password-recipes"; + +add_task(async function test_init_remote_recipe() { + const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const record1 = { + id: "some-fake-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + }; + await db.create(record1); + let parent = new LoginRecipesParent({ defaults: true }); + + let recipesParent = await parent.initializationPromise; + Assert.ok( + recipesParent instanceof LoginRecipesParent, + "Check initialization promise value which should be an instance of LoginRecipesParent" + ); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Initially 1 recipe based on our test record" + ); + let rsClient = recipesParent._rsClient; + + recipesParent.reset(); + await recipesParent.initializationPromise; + Assert.ok( + recipesParent instanceof LoginRecipesParent, + "Ensure that the instance of LoginRecipesParent has not changed after resetting" + ); + Assert.strictEqual( + rsClient, + recipesParent._rsClient, + "Resetting recipes should not modify the rs client" + ); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Initially 1 recipe based on our test record" + ); + await db.clear(); +}); + +add_task(async function test_add_recipe_sync() { + const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const record1 = { + id: "some-fake-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + }; + await db.create(record1); + let parent = new LoginRecipesParent({ defaults: true }); + let recipesParent = await parent.initializationPromise; + + const record2 = { + id: "some-fake-ID-2", + hosts: ["www.testDomain2.com"], + description: "Some description here. Wow it changed!", + usernameSelector: "#username", + }; + const payload = { + current: [record1, record2], + created: [record2], + updated: [], + deleted: [], + }; + await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", { + data: payload, + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 2, + "New recipe from sync event added successfully" + ); + await db.clear(); +}); + +add_task(async function test_remove_recipe_sync() { + const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const record1 = { + id: "some-fake-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + }; + await db.create(record1); + let parent = new LoginRecipesParent({ defaults: true }); + let recipesParent = await parent.initializationPromise; + + const deletePayload = { + current: [], + created: [], + updated: [], + deleted: [record1], + }; + await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", { + data: deletePayload, + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 0, + "Recipes successfully deleted on sync event" + ); + await db.clear(); +}); + +add_task(async function test_malformed_recipes_in_db() { + const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const malformedRecord = { + id: "some-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + fieldThatDoesNotExist: "value", + }; + await db.create(malformedRecord); + let parent = new LoginRecipesParent({ defaults: true }); + try { + await parent.initializationPromise; + } catch (e) { + Assert.ok( + e == "There were 1 recipe error(s)", + "It should throw an error because of field that does not match the schema" + ); + } + + await db.clear(); + const missingHostsRecord = { + id: "some-ID", + description: "Some description here", + usernameSelector: "#username", + }; + await db.create(missingHostsRecord); + parent = new LoginRecipesParent({ defaults: true }); + try { + await parent.initializationPromise; + } catch (e) { + Assert.ok( + e == "There were 1 recipe error(s)", + "It should throw an error because of missing hosts field" + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/xpcshell.ini b/toolkit/components/passwordmgr/test/unit/xpcshell.ini index 962adfd74742..a1c343fa379f 100644 --- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini +++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini @@ -1,6 +1,6 @@ [DEFAULT] head = head.js -skip-if = os == "android" # Not supported on GV because we can't add/remove from storage. +skip-if = os == "android" || toolkit == "android" # Not supported on GV because we can't add/remove from storage. support-files = data/** # Test logins.json file access, not applicable to Android. @@ -56,6 +56,8 @@ skip-if = os != "win" skip-if = os == "android" # Not packaged/used on Android [test_recipes_add.js] [test_recipes_content.js] +[test_remote_recipes.js] +skip-if = os == "android" [test_search_schemeUpgrades.js] [test_shadowHTTPLogins.js] [test_storage.js]