Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this
3
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
"use strict";
6
7
const EXPORTED_SYMBOLS = ["LoginRecipesContent", "LoginRecipesParent"];
8
9
const REQUIRED_KEYS = ["hosts"];
10
const OPTIONAL_KEYS = [
11
"description",
12
"notPasswordSelector",
13
"notUsernameSelector",
14
"passwordSelector",
15
"pathRegex",
16
"usernameSelector",
17
];
18
const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS);
19
20
const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
21
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
22
const { XPCOMUtils } = ChromeUtils.import(
24
);
25
26
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
27
28
ChromeUtils.defineModuleGetter(
29
this,
30
"LoginHelper",
32
);
33
34
XPCOMUtils.defineLazyGetter(this, "log", () =>
35
LoginHelper.createLogger("LoginRecipes")
36
);
37
38
/**
39
* Create an instance of the object to manage recipes in the parent process.
40
* Consumers should wait until {@link initializationPromise} resolves before
41
* calling methods on the object.
42
*
43
* @constructor
44
* @param {String} [aOptions.defaults=null] the URI to load the recipes from.
45
* If it's null, nothing is loaded.
46
*
47
*/
48
function LoginRecipesParent(aOptions = { defaults: null }) {
49
if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
50
throw new Error(
51
"LoginRecipesParent should only be used from the main process"
52
);
53
}
54
this._defaults = aOptions.defaults;
55
this.reset();
56
}
57
58
LoginRecipesParent.prototype = {
59
/**
60
* Promise resolved with an instance of itself when the module is ready.
61
*
62
* @type {Promise}
63
*/
64
initializationPromise: null,
65
66
/**
67
* @type {bool} Whether default recipes were loaded at construction time.
68
*/
69
_defaults: null,
70
71
/**
72
* @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes.
73
* e.g. "example.com:8080" => Set({...})
74
*/
75
_recipesByHost: null,
76
77
/**
78
* @param {Object} aRecipes an object containing recipes to load for use. The object
79
* should be compatible with JSON (e.g. no RegExp).
80
* @return {Promise} resolving when the recipes are loaded
81
*/
82
load(aRecipes) {
83
let recipeErrors = 0;
84
for (let rawRecipe of aRecipes.siteRecipes) {
85
try {
86
rawRecipe.pathRegex = rawRecipe.pathRegex
87
? new RegExp(rawRecipe.pathRegex)
88
: undefined;
89
this.add(rawRecipe);
90
} catch (ex) {
91
recipeErrors++;
92
log.error("Error loading recipe", rawRecipe, ex);
93
}
94
}
95
96
if (recipeErrors) {
97
return Promise.reject(`There were ${recipeErrors} recipe error(s)`);
98
}
99
100
return Promise.resolve();
101
},
102
103
/**
104
* Reset the set of recipes to the ones from the time of construction.
105
*/
106
reset() {
107
log.debug("Resetting recipes with defaults:", this._defaults);
108
this._recipesByHost = new Map();
109
110
if (this._defaults) {
111
let channel = NetUtil.newChannel({
112
uri: NetUtil.newURI(this._defaults, "UTF-8"),
113
loadUsingSystemPrincipal: true,
114
});
115
channel.contentType = "application/json";
116
117
try {
118
this.initializationPromise = new Promise(function(resolve) {
119
NetUtil.asyncFetch(channel, function(stream, result) {
120
if (!Components.isSuccessCode(result)) {
121
throw new Error("Error fetching recipe file:" + result);
122
}
123
let count = stream.available();
124
let data = NetUtil.readInputStreamToString(stream, count, {
125
charset: "UTF-8",
126
});
127
resolve(JSON.parse(data));
128
});
129
})
130
.then(recipes => {
131
Services.ppmm.broadcastAsyncMessage("clearRecipeCache");
132
return this.load(recipes);
133
})
134
.then(resolve => {
135
return this;
136
});
137
} catch (e) {
138
throw new Error("Error reading recipe file:" + e);
139
}
140
} else {
141
this.initializationPromise = Promise.resolve(this);
142
}
143
},
144
145
/**
146
* Validate the recipe is sane and then add it to the set of recipes.
147
*
148
* @param {Object} recipe
149
*/
150
add(recipe) {
151
log.debug("Adding recipe:", recipe);
152
let recipeKeys = Object.keys(recipe);
153
let unknownKeys = recipeKeys.filter(key => !SUPPORTED_KEYS.includes(key));
154
if (unknownKeys.length) {
155
throw new Error(
156
"The following recipe keys aren't supported: " + unknownKeys.join(", ")
157
);
158
}
159
160
let missingRequiredKeys = REQUIRED_KEYS.filter(
161
key => !recipeKeys.includes(key)
162
);
163
if (missingRequiredKeys.length) {
164
throw new Error(
165
"The following required recipe keys are missing: " +
166
missingRequiredKeys.join(", ")
167
);
168
}
169
170
if (!Array.isArray(recipe.hosts)) {
171
throw new Error("'hosts' must be a array");
172
}
173
174
if (!recipe.hosts.length) {
175
throw new Error("'hosts' must be a non-empty array");
176
}
177
178
if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") {
179
throw new Error("'pathRegex' must be a regular expression");
180
}
181
182
const OPTIONAL_STRING_PROPS = [
183
"description",
184
"passwordSelector",
185
"usernameSelector",
186
];
187
for (let prop of OPTIONAL_STRING_PROPS) {
188
if (recipe[prop] && typeof recipe[prop] != "string") {
189
throw new Error(`'${prop}' must be a string`);
190
}
191
}
192
193
// Add the recipe to the map for each host
194
for (let host of recipe.hosts) {
195
if (!this._recipesByHost.has(host)) {
196
this._recipesByHost.set(host, new Set());
197
}
198
this._recipesByHost.get(host).add(recipe);
199
}
200
},
201
202
/**
203
* Currently only exact host matches are returned but this will eventually handle parent domains.
204
*
205
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
206
* @return {Set} of recipes that apply to the host ordered by host priority
207
*/
208
getRecipesForHost(aHost) {
209
let hostRecipes = this._recipesByHost.get(aHost);
210
if (!hostRecipes) {
211
return new Set();
212
}
213
214
return hostRecipes;
215
},
216
};
217
218
this.LoginRecipesContent = {
219
_recipeCache: new WeakMap(),
220
221
_clearRecipeCache() {
222
log.debug("_clearRecipeCache");
223
this._recipeCache = new WeakMap();
224
},
225
226
/**
227
* Locally caches recipes for a given host.
228
*
229
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
230
* @param {Object} win - the window of the host
231
* @param {Set} recipes - recipes that apply to the host
232
*/
233
cacheRecipes(aHost, win, recipes) {
234
log.debug("cacheRecipes: for:", aHost);
235
let recipeMap = this._recipeCache.get(win);
236
237
if (!recipeMap) {
238
recipeMap = new Map();
239
this._recipeCache.set(win, recipeMap);
240
}
241
242
recipeMap.set(aHost, recipes);
243
},
244
245
/**
246
* Tries to fetch recipes for a given host, using a local cache if possible.
247
* Otherwise, the recipes are cached for later use.
248
*
249
* @param {JSWindowActor} aActor - actor making request
250
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
251
* @param {Object} win - the window of the host
252
* @return {Set} of recipes that apply to the host
253
*/
254
getRecipes(aActor, aHost, win) {
255
let recipes;
256
let recipeMap = this._recipeCache.get(win);
257
258
if (recipeMap) {
259
recipes = recipeMap.get(aHost);
260
261
if (recipes) {
262
return recipes;
263
}
264
}
265
266
log.warn("getRecipes: falling back to a synchronous message for:", aHost);
267
recipes = Services.cpmm.sendSyncMessage("PasswordManager:findRecipes", {
268
formOrigin: aHost,
269
})[0];
270
this.cacheRecipes(aHost, win, recipes);
271
272
return recipes;
273
},
274
275
/**
276
* @param {Set} aRecipes - Possible recipes that could apply to the form
277
* @param {FormLike} aForm - We use a form instead of just a URL so we can later apply
278
* tests to the page contents.
279
* @return {Set} a subset of recipes that apply to the form with the order preserved
280
*/
281
_filterRecipesForForm(aRecipes, aForm) {
282
let formDocURL = aForm.ownerDocument.location;
283
let hostRecipes = aRecipes;
284
let recipes = new Set();
285
log.debug("_filterRecipesForForm", aRecipes);
286
if (!hostRecipes) {
287
return recipes;
288
}
289
290
for (let hostRecipe of hostRecipes) {
291
if (
292
hostRecipe.pathRegex &&
293
!hostRecipe.pathRegex.test(formDocURL.pathname)
294
) {
295
continue;
296
}
297
recipes.add(hostRecipe);
298
}
299
300
return recipes;
301
},
302
303
/**
304
* Given a set of recipes that apply to the host, choose the one most applicable for
305
* overriding login fields in the form.
306
*
307
* @param {Set} aRecipes The set of recipes to consider for the form
308
* @param {FormLike} aForm The form where login fields exist.
309
* @return {Object} The recipe that is most applicable for the form.
310
*/
311
getFieldOverrides(aRecipes, aForm) {
312
let recipes = this._filterRecipesForForm(aRecipes, aForm);
313
log.debug("getFieldOverrides: filtered recipes:", recipes.size, recipes);
314
if (!recipes.size) {
315
return null;
316
}
317
318
let chosenRecipe = null;
319
// Find the first (most-specific recipe that involves field overrides).
320
for (let recipe of recipes) {
321
if (
322
!recipe.usernameSelector &&
323
!recipe.passwordSelector &&
324
!recipe.notUsernameSelector &&
325
!recipe.notPasswordSelector
326
) {
327
continue;
328
}
329
330
chosenRecipe = recipe;
331
break;
332
}
333
334
return chosenRecipe;
335
},
336
337
/**
338
* @param {HTMLElement} aParent the element to query for the selector from.
339
* @param {CSSSelector} aSelector the CSS selector to query for the login field.
340
* @return {HTMLElement|null}
341
*/
342
queryLoginField(aParent, aSelector) {
343
if (!aSelector) {
344
return null;
345
}
346
let field = aParent.ownerDocument.querySelector(aSelector);
347
if (!field) {
348
log.debug("Login field selector wasn't matched:", aSelector);
349
return null;
350
}
351
// ownerGlobal doesn't exist in content privileged windows.
352
if (
353
// eslint-disable-next-line mozilla/use-ownerGlobal
354
!(field instanceof aParent.ownerDocument.defaultView.HTMLInputElement)
355
) {
356
log.warn("Login field isn't an <input> so ignoring it:", aSelector);
357
return null;
358
}
359
return field;
360
},
361
};