Bug 1777343 - Implement simple origin controls attention indicator, r=willdurand...
[gecko.git] / toolkit / components / extensions / ExtensionPermissions.jsm
bloba64a9b54d71c268af3de9debf0100e3164c7d5a8
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* vim: set sts=2 sw=2 et tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4  * License, v. 2.0. If a copy of the MPL was not distributed with this
5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 "use strict";
8 const { XPCOMUtils } = ChromeUtils.importESModule(
9   "resource://gre/modules/XPCOMUtils.sys.mjs"
11 const { AppConstants } = ChromeUtils.import(
12   "resource://gre/modules/AppConstants.jsm"
15 const lazy = {};
17 ChromeUtils.defineESModuleGetters(lazy, {
18   FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
19   JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
20 });
22 XPCOMUtils.defineLazyModuleGetters(lazy, {
23   ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
24 });
26 XPCOMUtils.defineLazyGetter(
27   lazy,
28   "StartupCache",
29   () => lazy.ExtensionParent.StartupCache
32 ChromeUtils.defineModuleGetter(
33   lazy,
34   "KeyValueService",
35   "resource://gre/modules/kvstore.jsm"
38 XPCOMUtils.defineLazyGetter(
39   lazy,
40   "Management",
41   () => lazy.ExtensionParent.apiManager
44 var EXPORTED_SYMBOLS = ["ExtensionPermissions", "OriginControls"];
46 // This is the old preference file pre-migration to rkv
47 const FILE_NAME = "extension-preferences.json";
49 function emptyPermissions() {
50   return { permissions: [], origins: [] };
53 const DEFAULT_VALUE = JSON.stringify(emptyPermissions());
55 const VERSION_KEY = "_version";
56 const VERSION_VALUE = 1;
58 const KEY_PREFIX = "id-";
60 // Bug 1646182: remove once we fully migrate to rkv
61 let prefs;
63 // Bug 1646182: remove once we fully migrate to rkv
64 class LegacyPermissionStore {
65   async lazyInit() {
66     if (!this._initPromise) {
67       this._initPromise = this._init();
68     }
69     return this._initPromise;
70   }
72   async _init() {
73     let path = PathUtils.join(
74       Services.dirsvc.get("ProfD", Ci.nsIFile).path,
75       FILE_NAME
76     );
78     prefs = new lazy.JSONFile({ path });
79     prefs.data = {};
81     try {
82       prefs.data = await IOUtils.readJSON(path);
83     } catch (e) {
84       if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
85         Cu.reportError(e);
86       }
87     }
88   }
90   async has(extensionId) {
91     await this.lazyInit();
92     return !!prefs.data[extensionId];
93   }
95   async get(extensionId) {
96     await this.lazyInit();
98     let perms = prefs.data[extensionId];
99     if (!perms) {
100       perms = emptyPermissions();
101     }
103     return perms;
104   }
106   async put(extensionId, permissions) {
107     await this.lazyInit();
108     prefs.data[extensionId] = permissions;
109     prefs.saveSoon();
110   }
112   async delete(extensionId) {
113     await this.lazyInit();
114     if (prefs.data[extensionId]) {
115       delete prefs.data[extensionId];
116       prefs.saveSoon();
117     }
118   }
120   async uninitForTest() {
121     if (!this._initPromise) {
122       return;
123     }
125     await this._initPromise;
126     await prefs.finalize();
127     prefs = null;
128     this._initPromise = null;
129   }
131   async resetVersionForTest() {
132     throw new Error("Not supported");
133   }
136 class PermissionStore {
137   async _init() {
138     const storePath = lazy.FileUtils.getDir("ProfD", ["extension-store"]).path;
139     // Make sure the folder exists
140     await IOUtils.makeDirectory(storePath, { ignoreExisting: true });
141     this._store = await lazy.KeyValueService.getOrCreate(
142       storePath,
143       "permissions"
144     );
145     if (!(await this._store.has(VERSION_KEY))) {
146       await this.maybeMigrateData();
147     }
148   }
150   lazyInit() {
151     if (!this._initPromise) {
152       this._initPromise = this._init();
153     }
154     return this._initPromise;
155   }
157   validateMigratedData(json) {
158     let data = {};
159     for (let [extensionId, permissions] of Object.entries(json)) {
160       // If both arrays are empty there's no need to include the value since
161       // it's the default
162       if (
163         "permissions" in permissions &&
164         "origins" in permissions &&
165         (permissions.permissions.length || permissions.origins.length)
166       ) {
167         data[extensionId] = permissions;
168       }
169     }
170     return data;
171   }
173   async maybeMigrateData() {
174     let migrationWasSuccessful = false;
175     let oldStore = PathUtils.join(
176       Services.dirsvc.get("ProfD", Ci.nsIFile).path,
177       FILE_NAME
178     );
179     try {
180       await this.migrateFrom(oldStore);
181       migrationWasSuccessful = true;
182     } catch (e) {
183       if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
184         Cu.reportError(e);
185       }
186     }
188     await this._store.put(VERSION_KEY, VERSION_VALUE);
190     if (migrationWasSuccessful) {
191       IOUtils.remove(oldStore);
192     }
193   }
195   async migrateFrom(oldStore) {
196     // Some other migration job might have started and not completed, let's
197     // start from scratch
198     await this._store.clear();
200     let json = await IOUtils.readJSON(oldStore);
201     let data = this.validateMigratedData(json);
203     if (data) {
204       let entries = Object.entries(data).map(([extensionId, permissions]) => [
205         this.makeKey(extensionId),
206         JSON.stringify(permissions),
207       ]);
208       if (entries.length) {
209         await this._store.writeMany(entries);
210       }
211     }
212   }
214   makeKey(extensionId) {
215     // We do this so that the extensionId field cannot clash with internal
216     // fields like `_version`
217     return KEY_PREFIX + extensionId;
218   }
220   async has(extensionId) {
221     await this.lazyInit();
222     return this._store.has(this.makeKey(extensionId));
223   }
225   async get(extensionId) {
226     await this.lazyInit();
227     return this._store
228       .get(this.makeKey(extensionId), DEFAULT_VALUE)
229       .then(JSON.parse);
230   }
232   async put(extensionId, permissions) {
233     await this.lazyInit();
234     return this._store.put(
235       this.makeKey(extensionId),
236       JSON.stringify(permissions)
237     );
238   }
240   async delete(extensionId) {
241     await this.lazyInit();
242     return this._store.delete(this.makeKey(extensionId));
243   }
245   async resetVersionForTest() {
246     await this.lazyInit();
247     return this._store.delete(VERSION_KEY);
248   }
250   async uninitForTest() {
251     // Nothing special to do to unitialize, let's just
252     // make sure we're not in the middle of initialization
253     return this._initPromise;
254   }
257 // Bug 1646182: turn on rkv on all channels
258 function createStore(useRkv = AppConstants.NIGHTLY_BUILD) {
259   if (useRkv) {
260     return new PermissionStore();
261   }
262   return new LegacyPermissionStore();
265 let store = createStore();
267 var ExtensionPermissions = {
268   async _update(extensionId, perms) {
269     await store.put(extensionId, perms);
270     return lazy.StartupCache.permissions.set(extensionId, perms);
271   },
273   async _get(extensionId) {
274     return store.get(extensionId);
275   },
277   async _getCached(extensionId) {
278     return lazy.StartupCache.permissions.get(extensionId, () =>
279       this._get(extensionId)
280     );
281   },
283   /**
284    * Retrieves the optional permissions for the given extension.
285    * The information may be retrieved from the StartupCache, and otherwise fall
286    * back to data from the disk (and cache the result in the StartupCache).
287    *
288    * @param {string} extensionId The extensionId
289    * @returns {object} An object with "permissions" and "origins" array.
290    *   The object may be a direct reference to the storage or cache, so its
291    *   value should immediately be used and not be modified by callers.
292    */
293   get(extensionId) {
294     return this._getCached(extensionId);
295   },
297   _fixupAllUrlsPerms(perms) {
298     // Unfortunately, we treat <all_urls> as an API permission as well.
299     // If it is added to either, ensure it is added to both.
300     if (perms.origins.includes("<all_urls>")) {
301       perms.permissions.push("<all_urls>");
302     } else if (perms.permissions.includes("<all_urls>")) {
303       perms.origins.push("<all_urls>");
304     }
305   },
307   /**
308    * Add new permissions for the given extension.  `permissions` is
309    * in the format that is passed to browser.permissions.request().
310    *
311    * @param {string} extensionId The extension id
312    * @param {Object} perms Object with permissions and origins array.
313    * @param {EventEmitter} emitter optional object implementing emitter interfaces
314    */
315   async add(extensionId, perms, emitter) {
316     let { permissions, origins } = await this._get(extensionId);
318     let added = emptyPermissions();
320     this._fixupAllUrlsPerms(perms);
322     for (let perm of perms.permissions) {
323       if (!permissions.includes(perm)) {
324         added.permissions.push(perm);
325         permissions.push(perm);
326       }
327     }
329     for (let origin of perms.origins) {
330       origin = new MatchPattern(origin, { ignorePath: true }).pattern;
331       if (!origins.includes(origin)) {
332         added.origins.push(origin);
333         origins.push(origin);
334       }
335     }
337     if (added.permissions.length || added.origins.length) {
338       await this._update(extensionId, { permissions, origins });
339       lazy.Management.emit("change-permissions", { extensionId, added });
340       if (emitter) {
341         emitter.emit("add-permissions", added);
342       }
343     }
344   },
346   /**
347    * Revoke permissions from the given extension.  `permissions` is
348    * in the format that is passed to browser.permissions.request().
349    *
350    * @param {string} extensionId The extension id
351    * @param {Object} perms Object with permissions and origins array.
352    * @param {EventEmitter} emitter optional object implementing emitter interfaces
353    */
354   async remove(extensionId, perms, emitter) {
355     let { permissions, origins } = await this._get(extensionId);
357     let removed = emptyPermissions();
359     this._fixupAllUrlsPerms(perms);
361     for (let perm of perms.permissions) {
362       let i = permissions.indexOf(perm);
363       if (i >= 0) {
364         removed.permissions.push(perm);
365         permissions.splice(i, 1);
366       }
367     }
369     for (let origin of perms.origins) {
370       origin = new MatchPattern(origin, { ignorePath: true }).pattern;
372       let i = origins.indexOf(origin);
373       if (i >= 0) {
374         removed.origins.push(origin);
375         origins.splice(i, 1);
376       }
377     }
379     if (removed.permissions.length || removed.origins.length) {
380       await this._update(extensionId, { permissions, origins });
381       lazy.Management.emit("change-permissions", { extensionId, removed });
382       if (emitter) {
383         emitter.emit("remove-permissions", removed);
384       }
385     }
386   },
388   async removeAll(extensionId) {
389     lazy.StartupCache.permissions.delete(extensionId);
391     let removed = store.get(extensionId);
392     await store.delete(extensionId);
393     lazy.Management.emit("change-permissions", {
394       extensionId,
395       removed: await removed,
396     });
397   },
399   // This is meant for tests only
400   async _has(extensionId) {
401     return store.has(extensionId);
402   },
404   // This is meant for tests only
405   async _resetVersion() {
406     await store.resetVersionForTest();
407   },
409   // This is meant for tests only
410   _useLegacyStorageBackend: false,
412   // This is meant for tests only
413   async _uninit() {
414     await store.uninitForTest();
415     store = createStore(!this._useLegacyStorageBackend);
416   },
418   // Convenience listener members for all permission changes.
419   addListener(listener) {
420     lazy.Management.on("change-permissions", listener);
421   },
423   removeListener(listener) {
424     lazy.Management.off("change-permissions", listener);
425   },
428 var OriginControls = {
429   /**
430    * Get origin controls state for a given extension on a given host.
431    * @param {WebExtensionPolicy} policy
432    * @param {nsIURI} uri
433    * @returns {object} Extension origin controls for this host include:
434    *  @param {boolean} noAccess     no options, can never access host.
435    *  @param {boolean} whenClicked  option to access host when clicked.
436    *  @param {boolean} alwaysOn     option to always access this host.
437    *  @param {boolean} allDomains   option to access to all domains.
438    *  @param {boolean} hasAccess    extension currently has access to host.
439    */
440   getState(policy, uri) {
441     let allDomains = new MatchPattern("*://*/*");
442     let activeTab = policy.permissions.includes("activeTab");
443     let couldRequest = policy.extension.optionalOrigins.matches(uri);
444     let hasAccess = policy.canAccessURI(uri);
446     if (
447       !allDomains.matches(uri) ||
448       WebExtensionPolicy.isRestrictedURI(uri) ||
449       (!couldRequest && !hasAccess && !activeTab)
450     ) {
451       return { noAccess: true };
452     }
453     if (!couldRequest && !hasAccess && activeTab) {
454       return { whenClicked: true };
455     }
456     if (policy.allowedOrigins.subsumes(allDomains)) {
457       return { allDomains: true, hasAccess };
458     }
459     return {
460       whenClicked: true,
461       alwaysOn: true,
462       hasAccess,
463     };
464   },
466   // Whether to show the attention indicator for extension on current tab.
467   getAttention(policy, window) {
468     let state = this.getState(policy, window.gBrowser.currentURI);
469     return !!state.whenClicked && !state.hasAccess;
470   },
472   // Grant extension host permission to always run on this host.
473   setAlwaysOn(policy, uri) {
474     if (!policy.active) {
475       return;
476     }
477     let perms = { permissions: [], origins: ["*://" + uri.host] };
478     ExtensionPermissions.add(policy.id, perms, policy.extension);
479   },
481   // Revoke permission, extension should run only when clicked on this host.
482   setWhenClicked(policy, uri) {
483     if (!policy.active) {
484       return;
485     }
486     let perms = { permissions: [], origins: ["*://" + uri.host] };
487     ExtensionPermissions.remove(policy.id, perms, policy.extension);
488   },
490   /**
491    * Get origin controls messages (fluent IDs) to be shown to users for a given
492    * extension on a given host.
493    *
494    * @param {WebExtensionPolicy} policy
495    * @param {nsIURI} uri
496    * @returns {object|null} An object with origin controls message IDs or
497    *                        `null` when there is no message for the state.
498    *  @param {string} default      the message ID corresponding to the state
499    *                               that should be displayed by default.
500    *  @param {string|null} onHover an optional message ID to be shown when
501    *                               users hover interactive elements (e.g. a
502    *                               button).
503    */
504   getStateMessageIDs(policy, uri) {
505     const state = this.getState(policy, uri);
507     // TODO: add support for temporary access.
509     if (state.noAccess) {
510       return {
511         default: "origin-controls-state-no-access",
512         onHover: null,
513       };
514     }
516     if (state.allDomains || (state.alwaysOn && state.hasAccess)) {
517       return {
518         default: "origin-controls-state-always-on",
519         onHover: null,
520       };
521     }
523     if (state.whenClicked) {
524       return {
525         default: "origin-controls-state-when-clicked",
526         onHover: "origin-controls-state-hover-run-visit-only",
527       };
528     }
530     return null;
531   },