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/. */
8 const { XPCOMUtils } = ChromeUtils.importESModule(
9 "resource://gre/modules/XPCOMUtils.sys.mjs"
11 const { AppConstants } = ChromeUtils.import(
12 "resource://gre/modules/AppConstants.jsm"
17 ChromeUtils.defineESModuleGetters(lazy, {
18 FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
19 JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
22 XPCOMUtils.defineLazyModuleGetters(lazy, {
23 ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
26 XPCOMUtils.defineLazyGetter(
29 () => lazy.ExtensionParent.StartupCache
32 ChromeUtils.defineModuleGetter(
35 "resource://gre/modules/kvstore.jsm"
38 XPCOMUtils.defineLazyGetter(
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
63 // Bug 1646182: remove once we fully migrate to rkv
64 class LegacyPermissionStore {
66 if (!this._initPromise) {
67 this._initPromise = this._init();
69 return this._initPromise;
73 let path = PathUtils.join(
74 Services.dirsvc.get("ProfD", Ci.nsIFile).path,
78 prefs = new lazy.JSONFile({ path });
82 prefs.data = await IOUtils.readJSON(path);
84 if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
90 async has(extensionId) {
91 await this.lazyInit();
92 return !!prefs.data[extensionId];
95 async get(extensionId) {
96 await this.lazyInit();
98 let perms = prefs.data[extensionId];
100 perms = emptyPermissions();
106 async put(extensionId, permissions) {
107 await this.lazyInit();
108 prefs.data[extensionId] = permissions;
112 async delete(extensionId) {
113 await this.lazyInit();
114 if (prefs.data[extensionId]) {
115 delete prefs.data[extensionId];
120 async uninitForTest() {
121 if (!this._initPromise) {
125 await this._initPromise;
126 await prefs.finalize();
128 this._initPromise = null;
131 async resetVersionForTest() {
132 throw new Error("Not supported");
136 class PermissionStore {
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(
145 if (!(await this._store.has(VERSION_KEY))) {
146 await this.maybeMigrateData();
151 if (!this._initPromise) {
152 this._initPromise = this._init();
154 return this._initPromise;
157 validateMigratedData(json) {
159 for (let [extensionId, permissions] of Object.entries(json)) {
160 // If both arrays are empty there's no need to include the value since
163 "permissions" in permissions &&
164 "origins" in permissions &&
165 (permissions.permissions.length || permissions.origins.length)
167 data[extensionId] = permissions;
173 async maybeMigrateData() {
174 let migrationWasSuccessful = false;
175 let oldStore = PathUtils.join(
176 Services.dirsvc.get("ProfD", Ci.nsIFile).path,
180 await this.migrateFrom(oldStore);
181 migrationWasSuccessful = true;
183 if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
188 await this._store.put(VERSION_KEY, VERSION_VALUE);
190 if (migrationWasSuccessful) {
191 IOUtils.remove(oldStore);
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);
204 let entries = Object.entries(data).map(([extensionId, permissions]) => [
205 this.makeKey(extensionId),
206 JSON.stringify(permissions),
208 if (entries.length) {
209 await this._store.writeMany(entries);
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;
220 async has(extensionId) {
221 await this.lazyInit();
222 return this._store.has(this.makeKey(extensionId));
225 async get(extensionId) {
226 await this.lazyInit();
228 .get(this.makeKey(extensionId), DEFAULT_VALUE)
232 async put(extensionId, permissions) {
233 await this.lazyInit();
234 return this._store.put(
235 this.makeKey(extensionId),
236 JSON.stringify(permissions)
240 async delete(extensionId) {
241 await this.lazyInit();
242 return this._store.delete(this.makeKey(extensionId));
245 async resetVersionForTest() {
246 await this.lazyInit();
247 return this._store.delete(VERSION_KEY);
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;
257 // Bug 1646182: turn on rkv on all channels
258 function createStore(useRkv = AppConstants.NIGHTLY_BUILD) {
260 return new PermissionStore();
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);
273 async _get(extensionId) {
274 return store.get(extensionId);
277 async _getCached(extensionId) {
278 return lazy.StartupCache.permissions.get(extensionId, () =>
279 this._get(extensionId)
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).
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.
294 return this._getCached(extensionId);
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>");
308 * Add new permissions for the given extension. `permissions` is
309 * in the format that is passed to browser.permissions.request().
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
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);
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);
337 if (added.permissions.length || added.origins.length) {
338 await this._update(extensionId, { permissions, origins });
339 lazy.Management.emit("change-permissions", { extensionId, added });
341 emitter.emit("add-permissions", added);
347 * Revoke permissions from the given extension. `permissions` is
348 * in the format that is passed to browser.permissions.request().
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
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);
364 removed.permissions.push(perm);
365 permissions.splice(i, 1);
369 for (let origin of perms.origins) {
370 origin = new MatchPattern(origin, { ignorePath: true }).pattern;
372 let i = origins.indexOf(origin);
374 removed.origins.push(origin);
375 origins.splice(i, 1);
379 if (removed.permissions.length || removed.origins.length) {
380 await this._update(extensionId, { permissions, origins });
381 lazy.Management.emit("change-permissions", { extensionId, removed });
383 emitter.emit("remove-permissions", removed);
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", {
395 removed: await removed,
399 // This is meant for tests only
400 async _has(extensionId) {
401 return store.has(extensionId);
404 // This is meant for tests only
405 async _resetVersion() {
406 await store.resetVersionForTest();
409 // This is meant for tests only
410 _useLegacyStorageBackend: false,
412 // This is meant for tests only
414 await store.uninitForTest();
415 store = createStore(!this._useLegacyStorageBackend);
418 // Convenience listener members for all permission changes.
419 addListener(listener) {
420 lazy.Management.on("change-permissions", listener);
423 removeListener(listener) {
424 lazy.Management.off("change-permissions", listener);
428 var OriginControls = {
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.
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);
447 !allDomains.matches(uri) ||
448 WebExtensionPolicy.isRestrictedURI(uri) ||
449 (!couldRequest && !hasAccess && !activeTab)
451 return { noAccess: true };
453 if (!couldRequest && !hasAccess && activeTab) {
454 return { whenClicked: true };
456 if (policy.allowedOrigins.subsumes(allDomains)) {
457 return { allDomains: true, hasAccess };
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;
472 // Grant extension host permission to always run on this host.
473 setAlwaysOn(policy, uri) {
474 if (!policy.active) {
477 let perms = { permissions: [], origins: ["*://" + uri.host] };
478 ExtensionPermissions.add(policy.id, perms, policy.extension);
481 // Revoke permission, extension should run only when clicked on this host.
482 setWhenClicked(policy, uri) {
483 if (!policy.active) {
486 let perms = { permissions: [], origins: ["*://" + uri.host] };
487 ExtensionPermissions.remove(policy.id, perms, policy.extension);
491 * Get origin controls messages (fluent IDs) to be shown to users for a given
492 * extension on a given host.
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
504 getStateMessageIDs(policy, uri) {
505 const state = this.getState(policy, uri);
507 // TODO: add support for temporary access.
509 if (state.noAccess) {
511 default: "origin-controls-state-no-access",
516 if (state.allDomains || (state.alwaysOn && state.hasAccess)) {
518 default: "origin-controls-state-always-on",
523 if (state.whenClicked) {
525 default: "origin-controls-state-when-clicked",
526 onHover: "origin-controls-state-hover-run-visit-only",