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
this._recipeCache = new WeakMap();
223
},
224
225
/**
226
* Locally caches recipes for a given host.
227
*
228
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
229
* @param {Object} win - the window of the host
230
* @param {Set} recipes - recipes that apply to the host
231
*/
232
cacheRecipes(aHost, win, recipes) {
233
log.debug("cacheRecipes: for:", aHost);
234
let recipeMap = this._recipeCache.get(win);
235
236
if (!recipeMap) {
237
recipeMap = new Map();
238
this._recipeCache.set(win, recipeMap);
239
}
240
241
recipeMap.set(aHost, recipes);
242
},
243
244
/**
245
* Tries to fetch recipes for a given host, using a local cache if possible.
246
* Otherwise, the recipes are cached for later use.
247
*
248
* @param {JSWindowActor} aActor - actor making request
249
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
250
* @param {Object} win - the window of the host
251
* @return {Set} of recipes that apply to the host
252
*/
253
getRecipes(aActor, aHost, win) {
254
let recipes;
255
let recipeMap = this._recipeCache.get(win);
256
257
if (recipeMap) {
258
recipes = recipeMap.get(aHost);
259
260
if (recipes) {
261
return recipes;
262
}
263
}
264
265
log.warn("getRecipes: falling back to a synchronous message for:", aHost);
266
recipes = Services.cpmm.sendSyncMessage("PasswordManager:findRecipes", {
267
formOrigin: aHost,
268
})[0];
269
this.cacheRecipes(aHost, win, recipes);
270
271
return recipes;
272
},
273
274
/**
275
* @param {Set} aRecipes - Possible recipes that could apply to the form
276
* @param {FormLike} aForm - We use a form instead of just a URL so we can later apply
277
* tests to the page contents.
278
* @return {Set} a subset of recipes that apply to the form with the order preserved
279
*/
280
_filterRecipesForForm(aRecipes, aForm) {
281
let formDocURL = aForm.ownerDocument.location;
282
let hostRecipes = aRecipes;
283
let recipes = new Set();
284
log.debug("_filterRecipesForForm", aRecipes);
285
if (!hostRecipes) {
286
return recipes;
287
}
288
289
for (let hostRecipe of hostRecipes) {
290
if (
291
hostRecipe.pathRegex &&
292
!hostRecipe.pathRegex.test(formDocURL.pathname)
293
) {
294
continue;
295
}
296
recipes.add(hostRecipe);
297
}
298
299
return recipes;
300
},
301
302
/**
303
* Given a set of recipes that apply to the host, choose the one most applicable for
304
* overriding login fields in the form.
305
*
306
* @param {Set} aRecipes The set of recipes to consider for the form
307
* @param {FormLike} aForm The form where login fields exist.
308
* @return {Object} The recipe that is most applicable for the form.
309
*/
310
getFieldOverrides(aRecipes, aForm) {
311
let recipes = this._filterRecipesForForm(aRecipes, aForm);
312
log.debug("getFieldOverrides: filtered recipes:", recipes);
313
if (!recipes.size) {
314
return null;
315
}
316
317
let chosenRecipe = null;
318
// Find the first (most-specific recipe that involves field overrides).
319
for (let recipe of recipes) {
320
if (
321
!recipe.usernameSelector &&
322
!recipe.passwordSelector &&
323
!recipe.notUsernameSelector &&
324
!recipe.notPasswordSelector
325
) {
326
continue;
327
}
328
329
chosenRecipe = recipe;
330
break;
331
}
332
333
return chosenRecipe;
334
},
335
336
/**
337
* @param {HTMLElement} aParent the element to query for the selector from.
338
* @param {CSSSelector} aSelector the CSS selector to query for the login field.
339
* @return {HTMLElement|null}
340
*/
341
queryLoginField(aParent, aSelector) {
342
if (!aSelector) {
343
return null;
344
}
345
let field = aParent.ownerDocument.querySelector(aSelector);
346
if (!field) {
347
log.debug("Login field selector wasn't matched:", aSelector);
348
return null;
349
}
350
// ownerGlobal doesn't exist in content privileged windows.
351
if (
352
// eslint-disable-next-line mozilla/use-ownerGlobal
353
!(field instanceof aParent.ownerDocument.defaultView.HTMLInputElement)
354
) {
355
log.warn("Login field isn't an <input> so ignoring it:", aSelector);
356
return null;
357
}
358
return field;
359
},
360
};