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/. */
7 import { computeSha1HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
8 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
13 ChromeUtils.defineESModuleGetters(lazy, {
14 AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
15 AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
16 ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
17 FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
18 JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
19 KeyValueService: "resource://gre/modules/kvstore.sys.mjs",
22 ChromeUtils.defineLazyGetter(
25 () => lazy.ExtensionParent.StartupCache
28 ChromeUtils.defineLazyGetter(
31 () => lazy.ExtensionParent.apiManager
34 function emptyPermissions() {
35 return { permissions: [], origins: [] };
38 const DEFAULT_VALUE = JSON.stringify(emptyPermissions());
40 const KEY_PREFIX = "id-";
42 // This is the old preference file pre-migration to rkv.
43 const OLD_JSON_FILENAME = "extension-preferences.json";
44 // This is the old path to the rkv store dir (which used to be shared with ExtensionScriptingStore).
45 const OLD_RKV_DIRNAME = "extension-store";
46 // This is the new path to the rkv store dir.
47 const RKV_DIRNAME = "extension-store-permissions";
49 const VERSION_KEY = "_version";
51 const VERSION_VALUE = 1;
53 // Bug 1646182: remove once we fully migrate to rkv
56 // Bug 1646182: remove once we fully migrate to rkv
57 class LegacyPermissionStore {
59 if (!this._initPromise) {
60 this._initPromise = this._init();
62 return this._initPromise;
66 let path = PathUtils.join(
67 Services.dirsvc.get("ProfD", Ci.nsIFile).path,
71 prefs = new lazy.JSONFile({ path });
75 prefs.data = await IOUtils.readJSON(path);
77 if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
83 async has(extensionId) {
84 await this.lazyInit();
85 return !!prefs.data[extensionId];
88 async get(extensionId) {
89 await this.lazyInit();
91 let perms = prefs.data[extensionId];
93 perms = emptyPermissions();
99 async put(extensionId, permissions) {
100 await this.lazyInit();
101 prefs.data[extensionId] = permissions;
105 async delete(extensionId) {
106 await this.lazyInit();
107 if (prefs.data[extensionId]) {
108 delete prefs.data[extensionId];
113 async uninitForTest() {
114 if (!this._initPromise) {
118 await this._initPromise;
119 await prefs.finalize();
121 this._initPromise = null;
124 async resetVersionForTest() {
125 throw new Error("Not supported");
129 class PermissionStore {
130 _shouldMigrateFromOldKVStorePath = AppConstants.NIGHTLY_BUILD;
133 const storePath = lazy.FileUtils.getDir("ProfD", [RKV_DIRNAME]).path;
134 // Make sure the folder exists
135 await IOUtils.makeDirectory(storePath, { ignoreExisting: true });
136 this._store = await lazy.KeyValueService.getOrCreateWithOptions(
139 { strategy: lazy.KeyValueService.RecoveryStrategy.RENAME }
141 if (!(await this._store.has(VERSION_KEY))) {
142 // If _shouldMigrateFromOldKVStorePath is true (default only on Nightly channel
143 // where the rkv store has been enabled by default for a while), we need to check
144 // if we would need to import data from the old kvstore path (ProfD/extensions-store)
145 // first, and fallback to try to import from the JSONFile if there was no data in
146 // the old kvstore path.
147 // NOTE: _shouldMigrateFromOldKVStorePath is also explicitly set to true in unit tests
148 // that are meant to explicitly cover this path also when running on on non-Nightly channels.
149 if (this._shouldMigrateFromOldKVStorePath) {
150 // Try to import data from the old kvstore path (ProfD/extensions-store).
151 await this.maybeImportFromOldKVStorePath();
152 if (!(await this._store.has(VERSION_KEY))) {
153 // There was no data in the old kvstore path, migrate any data
154 // available from the LegacyPermissionStore JSONFile if any.
155 await this.maybeMigrateDataFromOldJSONFile();
158 // On non-Nightly channels, where LegacyPermissionStore was still the
159 // only backend ever enabled, try to import permissions data from the
160 // legacy JSONFile, if any data is available there.
161 await this.maybeMigrateDataFromOldJSONFile();
167 if (!this._initPromise) {
168 this._initPromise = this._init();
170 return this._initPromise;
173 validateMigratedData(json) {
175 for (let [extensionId, permissions] of Object.entries(json)) {
176 // If both arrays are empty there's no need to include the value since
179 "permissions" in permissions &&
180 "origins" in permissions &&
181 (permissions.permissions.length || permissions.origins.length)
183 data[extensionId] = permissions;
189 async maybeMigrateDataFromOldJSONFile() {
190 let migrationWasSuccessful = false;
191 let oldStore = PathUtils.join(
192 Services.dirsvc.get("ProfD", Ci.nsIFile).path,
196 await this.migrateFrom(oldStore);
197 migrationWasSuccessful = true;
199 if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
204 await this._store.put(VERSION_KEY, VERSION_VALUE);
206 if (migrationWasSuccessful) {
207 IOUtils.remove(oldStore);
211 async maybeImportFromOldKVStorePath() {
213 const oldStorePath = lazy.FileUtils.getDir("ProfD", [
216 if (!(await IOUtils.exists(oldStorePath))) {
219 const oldStore = await lazy.KeyValueService.getOrCreate(
223 const enumerator = await oldStore.enumerate();
225 while (enumerator.hasMoreElements()) {
226 const { key, value } = enumerator.getNext();
227 kvpairs.push([key, value]);
230 // NOTE: we don't add a VERSION_KEY entry explicitly here because
231 // if the database was not empty the VERSION_KEY is already set to
232 // 1 and will be copied into the new file as part of the pairs
233 // written below (along with the entries for the actual extensions
235 if (kvpairs.length) {
236 await this._store.writeMany(kvpairs);
239 // NOTE: the old rkv store path used to be shared with the
240 // ExtensionScriptingStore, and so we are not removing the old
241 // rkv store dir here (that is going to be left to a separate
242 // migration we will be adding to ExtensionScriptingStore).
248 async migrateFrom(oldStore) {
249 // Some other migration job might have started and not completed, let's
250 // start from scratch
251 await this._store.clear();
253 let json = await IOUtils.readJSON(oldStore);
254 let data = this.validateMigratedData(json);
257 let entries = Object.entries(data).map(([extensionId, permissions]) => [
258 this.makeKey(extensionId),
259 JSON.stringify(permissions),
261 if (entries.length) {
262 await this._store.writeMany(entries);
267 makeKey(extensionId) {
268 // We do this so that the extensionId field cannot clash with internal
269 // fields like `_version`
270 return KEY_PREFIX + extensionId;
273 async has(extensionId) {
274 await this.lazyInit();
275 return this._store.has(this.makeKey(extensionId));
278 async get(extensionId) {
279 await this.lazyInit();
281 .get(this.makeKey(extensionId), DEFAULT_VALUE)
285 async put(extensionId, permissions) {
286 await this.lazyInit();
287 return this._store.put(
288 this.makeKey(extensionId),
289 JSON.stringify(permissions)
293 async delete(extensionId) {
294 await this.lazyInit();
295 return this._store.delete(this.makeKey(extensionId));
298 async resetVersionForTest() {
299 await this.lazyInit();
300 return this._store.delete(VERSION_KEY);
303 async uninitForTest() {
304 // Nothing special to do to unitialize, let's just
305 // make sure we're not in the middle of initialization
306 return this._initPromise;
310 // Bug 1646182: turn on rkv on all channels
311 function createStore(useRkv = AppConstants.NIGHTLY_BUILD) {
313 return new PermissionStore();
315 return new LegacyPermissionStore();
318 let store = createStore();
320 export var ExtensionPermissions = {
321 async _update(extensionId, perms) {
322 await store.put(extensionId, perms);
323 return lazy.StartupCache.permissions.set(extensionId, perms);
326 async _get(extensionId) {
327 return store.get(extensionId);
330 async _getCached(extensionId) {
331 return lazy.StartupCache.permissions.get(extensionId, () =>
332 this._get(extensionId)
337 * Retrieves the optional permissions for the given extension.
338 * The information may be retrieved from the StartupCache, and otherwise fall
339 * back to data from the disk (and cache the result in the StartupCache).
341 * @param {string} extensionId The extensionId
342 * @returns {object} An object with "permissions" and "origins" array.
343 * The object may be a direct reference to the storage or cache, so its
344 * value should immediately be used and not be modified by callers.
347 return this._getCached(extensionId);
350 _fixupAllUrlsPerms(perms) {
351 // Unfortunately, we treat <all_urls> as an API permission as well.
352 // If it is added to either, ensure it is added to both.
353 if (perms.origins.includes("<all_urls>")) {
354 perms.permissions.push("<all_urls>");
355 } else if (perms.permissions.includes("<all_urls>")) {
356 perms.origins.push("<all_urls>");
361 * Add new permissions for the given extension. `permissions` is
362 * in the format that is passed to browser.permissions.request().
364 * @param {string} extensionId The extension id
365 * @param {object} perms Object with permissions and origins array.
366 * @param {EventEmitter} [emitter] optional object implementing emitter interfaces
368 async add(extensionId, perms, emitter) {
369 let { permissions, origins } = await this._get(extensionId);
371 let added = emptyPermissions();
373 this._fixupAllUrlsPerms(perms);
375 for (let perm of perms.permissions) {
376 if (!permissions.includes(perm)) {
377 added.permissions.push(perm);
378 permissions.push(perm);
382 for (let origin of perms.origins) {
383 origin = new MatchPattern(origin, { ignorePath: true }).pattern;
384 if (!origins.includes(origin)) {
385 added.origins.push(origin);
386 origins.push(origin);
390 if (added.permissions.length || added.origins.length) {
391 await this._update(extensionId, { permissions, origins });
392 lazy.Management.emit("change-permissions", { extensionId, added });
394 emitter.emit("add-permissions", added);
400 * Revoke permissions from the given extension. `permissions` is
401 * in the format that is passed to browser.permissions.request().
403 * @param {string} extensionId The extension id
404 * @param {object} perms Object with permissions and origins array.
405 * @param {EventEmitter} [emitter] optional object implementing emitter interfaces
407 async remove(extensionId, perms, emitter) {
408 let { permissions, origins } = await this._get(extensionId);
410 let removed = emptyPermissions();
412 this._fixupAllUrlsPerms(perms);
414 for (let perm of perms.permissions) {
415 let i = permissions.indexOf(perm);
417 removed.permissions.push(perm);
418 permissions.splice(i, 1);
422 for (let origin of perms.origins) {
423 origin = new MatchPattern(origin, { ignorePath: true }).pattern;
425 let i = origins.indexOf(origin);
427 removed.origins.push(origin);
428 origins.splice(i, 1);
432 if (removed.permissions.length || removed.origins.length) {
433 await this._update(extensionId, { permissions, origins });
434 lazy.Management.emit("change-permissions", { extensionId, removed });
436 emitter.emit("remove-permissions", removed);
441 async removeAll(extensionId) {
442 lazy.StartupCache.permissions.delete(extensionId);
444 let removed = store.get(extensionId);
445 await store.delete(extensionId);
446 lazy.Management.emit("change-permissions", {
448 removed: await removed,
452 // This is meant for tests only
453 async _has(extensionId) {
454 return store.has(extensionId);
457 // This is meant for tests only
458 async _resetVersion() {
459 await store.resetVersionForTest();
462 // This is meant for tests only
463 _useLegacyStorageBackend: false,
465 // This is meant for tests only
466 async _uninit({ recreateStore = true } = {}) {
467 await store?.uninitForTest();
470 store = createStore(!this._useLegacyStorageBackend);
474 // This is meant for tests only
479 // Convenience listener members for all permission changes.
480 addListener(listener) {
481 lazy.Management.on("change-permissions", listener);
484 removeListener(listener) {
485 lazy.Management.off("change-permissions", listener);
489 export var OriginControls = {
490 allDomains: new MatchPattern("*://*/*"),
493 * @typedef {object} OriginControlState
494 * @param {boolean} noAccess no options, can never access host.
495 * @param {boolean} whenClicked option to access host when clicked.
496 * @param {boolean} alwaysOn option to always access this host.
497 * @param {boolean} allDomains option to access to all domains.
498 * @param {boolean} hasAccess extension currently has access to host.
499 * @param {boolean} temporaryAccess extension has temporary access to the tab.
503 * Get origin controls state for a given extension on a given tab.
505 * @param {WebExtensionPolicy} policy
506 * @param {NativeTab} nativeTab
507 * @returns {OriginControlState} Extension origin controls for this host include:
509 getState(policy, nativeTab) {
510 // Note: don't use the nativeTab directly because it's different on mobile.
511 let tab = policy?.extension?.tabManager.getWrapper(nativeTab);
512 let temporaryAccess = tab?.hasActiveTabPermission;
513 let uri = tab?.browser.currentURI;
516 return { noAccess: true };
519 // activeTab and the resulting whenClicked state is only applicable for MV2
520 // extensions with a browser action and MV3 extensions (with or without).
522 policy.permissions.includes("activeTab") &&
523 (policy.manifestVersion >= 3 || policy.extension?.hasBrowserActionUI);
525 let couldRequest = policy.extension.optionalOrigins.matches(uri);
526 let hasAccess = policy.canAccessURI(uri);
528 // If any of (MV2) content script patterns match the URI.
529 let csPatternMatches = false;
530 let quarantinedFrom = policy.quarantinedFromURI(uri);
532 if (policy.manifestVersion < 3 && !hasAccess) {
533 csPatternMatches = policy.contentScripts.some(cs =>
534 cs.matches.patterns.some(p => p.matches(uri))
536 // MV2 access through content scripts is implicit.
537 hasAccess = csPatternMatches && !quarantinedFrom;
540 // If extension is quarantined from this host, but could otherwise have
541 // access (via activeTab, optional, allowedOrigins or content scripts).
547 policy.allowedOrigins.matches(uri));
551 !this.allDomains.matches(uri) ||
552 WebExtensionPolicy.isRestrictedURI(uri) ||
553 (!couldRequest && !hasAccess && !activeTab)
555 return { noAccess: true, quarantined };
558 if (!couldRequest && !hasAccess && activeTab) {
559 return { whenClicked: true, temporaryAccess };
561 if (policy.allowedOrigins.subsumes(this.allDomains)) {
562 return { allDomains: true, hasAccess };
574 * Whether to show the attention indicator for extension on current tab. We
575 * usually show attention when:
577 * - some permissions are needed (in MV3+)
578 * - the extension is not allowed on the domain (quarantined)
580 * @param {WebExtensionPolicy} policy an extension's policy
581 * @param {Window} window The window for which we can get the attention state
582 * @returns {{attention: boolean, quarantined: boolean}}
584 getAttentionState(policy, window) {
585 if (policy?.manifestVersion >= 3) {
586 const state = this.getState(policy, window.gBrowser.selectedTab);
587 // quarantined is always false when the feature is disabled.
588 const quarantined = !!state.quarantined;
591 (!!state.whenClicked && !state.hasAccess && !state.temporaryAccess);
593 return { attention, quarantined };
596 // No need to check whether the Quarantined Domains feature is enabled
597 // here, it's already done in `getState()`.
598 const state = this.getState(policy, window.gBrowser.selectedTab);
599 const attention = !!state.quarantined;
600 // If it needs attention, it's because of the quarantined domains.
601 return { attention, quarantined: attention };
604 // Grant extension host permission to always run on this host.
605 setAlwaysOn(policy, uri) {
606 if (!policy.active) {
609 let perms = { permissions: [], origins: ["*://" + uri.host] };
610 return ExtensionPermissions.add(policy.id, perms, policy.extension);
613 // Revoke permission, extension should run only when clicked on this host.
614 setWhenClicked(policy, uri) {
615 if (!policy.active) {
618 let perms = { permissions: [], origins: ["*://" + uri.host] };
619 return ExtensionPermissions.remove(policy.id, perms, policy.extension);
623 * @typedef {object} FluentIdInfo
624 * @param {string} default the message ID corresponding to the state
625 * that should be displayed by default.
626 * @param {string | null} onHover an optional message ID to be shown when
627 * users hover interactive elements (e.g. a
632 * Get origin controls messages (fluent IDs) to be shown to users for a given
633 * extension on a given host. The messages might be different for extensions
634 * with a browser action (that might or might not open a popup).
636 * @param {object} params
637 * @param {WebExtensionPolicy} params.policy an extension's policy
638 * @param {NativeTab} params.tab the current tab
639 * @param {boolean} params.isAction this should be true for
640 * extensions with a browser
641 * action, false otherwise.
642 * @param {boolean} params.hasPopup this should be true when the
643 * browser action opens a popup,
646 * @returns {FluentIdInfo?} An object with origin controls message IDs or
647 * `null` when there is no message for the state.
649 getStateMessageIDs({ policy, tab, isAction = false, hasPopup = false }) {
650 const state = this.getState(policy, tab);
652 const onHoverForAction = hasPopup
653 ? "origin-controls-state-runnable-hover-open"
654 : "origin-controls-state-runnable-hover-run";
656 if (state.noAccess) {
658 default: state.quarantined
659 ? "origin-controls-state-quarantined"
660 : "origin-controls-state-no-access",
661 onHover: isAction ? onHoverForAction : null,
665 if (state.allDomains || (state.alwaysOn && state.hasAccess)) {
667 default: "origin-controls-state-always-on",
668 onHover: isAction ? onHoverForAction : null,
672 if (state.whenClicked) {
674 default: state.temporaryAccess
675 ? "origin-controls-state-temporary-access"
676 : "origin-controls-state-when-clicked",
677 onHover: "origin-controls-state-hover-run-visit-only",
685 export var QuarantinedDomains = {
686 getUserAllowedAddonIdPrefName(addonId) {
687 return `${this.PREF_ADDONS_BRANCH_NAME}${addonId}`;
689 isUserAllowedAddonId(addonId) {
690 return Services.prefs.getBoolPref(
691 this.getUserAllowedAddonIdPrefName(addonId),
695 setUserAllowedAddonIdPref(addonId, userAllowed) {
696 Services.prefs.setBoolPref(
697 this.getUserAllowedAddonIdPrefName(addonId),
701 clearUserPref(addonId) {
702 Services.prefs.clearUserPref(this.getUserAllowedAddonIdPrefName(addonId));
705 // Implementation internals.
707 PREF_ADDONS_BRANCH_NAME: `extensions.quarantineIgnoredByUser.`,
708 PREF_DOMAINSLIST_NAME: `extensions.quarantinedDomains.list`,
711 if (this._initialized) {
715 const onUserAllowedPrefChanged = this._onUserAllowedPrefChanged.bind(this);
716 Services.prefs.addObserver(
717 this.PREF_ADDONS_BRANCH_NAME,
718 onUserAllowedPrefChanged
721 const onUpdatedDomainsListTelemetry =
722 this._onUpdatedDomainsListTelemetry.bind(this);
723 XPCOMUtils.defineLazyPreferenceGetter(
725 "currentDomainsList",
726 this.PREF_DOMAINSLIST_NAME,
728 onUpdatedDomainsListTelemetry,
729 value => this._transformDomainsListPrefValue(value || "")
731 // Collect it at least once per session (and update it when the pref value changes).
732 onUpdatedDomainsListTelemetry();
734 const onAMRemoteSettingsSetPref =
735 this._onAMRemoteSettingsSetPref.bind(this);
736 Services.obs.addObserver(
737 onAMRemoteSettingsSetPref,
738 "am-remote-settings-setpref"
741 this._initialized = true;
743 async _onAMRemoteSettingsSetPref(subject, _topic) {
744 const { prefName, prefValue } = subject?.wrappedJSObject ?? {};
745 if (prefName !== this.PREF_DOMAINSLIST_NAME) {
748 Glean.extensionsQuarantinedDomains.remotehash.set(
749 computeSha1HashAsString(prefValue || "")
752 async _onUserAllowedPrefChanged(_subject, _topic, prefName) {
753 let addonId = prefName.slice(this.PREF_ADDONS_BRANCH_NAME.length);
755 if (!addonId || prefName !== this.getUserAllowedAddonIdPrefName(addonId)) {
759 // Notify listeners, e.g. to update details in TelemetryEnvironment.
760 const addon = await lazy.AddonManager.getAddonByID(addonId);
761 // Do not call onPropertyChanged listeners if the addon cannot be found
762 // anymore (e.g. it has been uninstalled).
764 lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", addon, [
765 "quarantineIgnoredByUser",
769 _onUpdatedDomainsListTelemetry(_subject, _topic, _prefName) {
770 Glean.extensionsQuarantinedDomains.listsize.set(
771 this.currentDomainsList.set.size
773 Glean.extensionsQuarantinedDomains.listhash.set(
774 this.currentDomainsList.hash
777 _transformDomainsListPrefValue(value) {
780 // NOTE: using a sha1 hash to make sure the resulting string will fit into the
781 // unified telemetry scalar string the glean metrics is mirrored to (which is
782 // limited to 50 characters).
783 hash: computeSha1HashAsString(value || ""),
788 .filter(v => v.length)
792 return { hash: "unexpected-error", set: new Set() };
796 QuarantinedDomains._init();
798 // Constants exported for testing purpose.