diff options
Diffstat (limited to 'browser/modules/SitePermissions.sys.mjs')
-rw-r--r-- | browser/modules/SitePermissions.sys.mjs | 1327 |
1 files changed, 1327 insertions, 0 deletions
diff --git a/browser/modules/SitePermissions.sys.mjs b/browser/modules/SitePermissions.sys.mjs new file mode 100644 index 0000000000..2f3f9210e2 --- /dev/null +++ b/browser/modules/SitePermissions.sys.mjs @@ -0,0 +1,1327 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +var gStringBundle = Services.strings.createBundle( + "chrome://browser/locale/sitePermissions.properties" +); + +/** + * A helper module to manage temporary permissions. + * + * Permissions are keyed by browser, so methods take a Browser + * element to identify the corresponding permission set. + * + * This uses a WeakMap to key browsers, so that entries are + * automatically cleared once the browser stops existing + * (once there are no other references to the browser object); + */ +const TemporaryPermissions = { + // This is a three level deep map with the following structure: + // + // Browser => { + // <baseDomain|origin>: { + // <permissionID>: {state: Number, expireTimeout: Number} + // } + // } + // + // Only the top level browser elements are stored via WeakMap. The WeakMap + // value is an object with URI baseDomains or origins as keys. The keys of + // that object are ids that identify permissions that were set for the + // specific URI. The final value is an object containing the permission state + // and the id of the timeout which will cause permission expiry. + // BLOCK permissions are keyed under baseDomain to prevent bypassing the block + // (see Bug 1492668). Any other permissions are keyed under origin. + _stateByBrowser: new WeakMap(), + + // Extract baseDomain from uri. Fallback to hostname on conversion error. + _uriToBaseDomain(uri) { + try { + return Services.eTLD.getBaseDomain(uri); + } catch (error) { + if ( + error.result !== Cr.NS_ERROR_HOST_IS_IP_ADDRESS && + error.result !== Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS + ) { + throw error; + } + return uri.host; + } + }, + + /** + * Generate keys to store temporary permissions under. The strict key is + * origin, non-strict is baseDomain. + * @param {nsIPrincipal} principal - principal to derive keys from. + * @returns {Object} keys - Object containing the generated permission keys. + * @returns {string} keys.strict - Key to be used for strict matching. + * @returns {string} keys.nonStrict - Key to be used for non-strict matching. + * @throws {Error} - Throws if principal is undefined or no valid permission key can + * be generated. + */ + _getKeysFromPrincipal(principal) { + return { strict: principal.origin, nonStrict: principal.baseDomain }; + }, + + /** + * Sets a new permission for the specified browser. + * @returns {boolean} whether the permission changed, effectively. + */ + set( + browser, + id, + state, + expireTimeMS, + principal = browser.contentPrincipal, + expireCallback + ) { + if ( + !browser || + !principal || + !SitePermissions.isSupportedPrincipal(principal) + ) { + return false; + } + let entry = this._stateByBrowser.get(browser); + if (!entry) { + entry = { browser: Cu.getWeakReference(browser), uriToPerm: {} }; + this._stateByBrowser.set(browser, entry); + } + let { uriToPerm } = entry; + // We store blocked permissions by baseDomain. Other states by origin. + let { strict, nonStrict } = this._getKeysFromPrincipal(principal); + let setKey; + let deleteKey; + // Differentiate between block and non-block permissions. If we store a + // block permission we need to delete old entries which may be set under + // origin before setting the new permission for baseDomain. For non-block + // permissions this is swapped. + if (state == SitePermissions.BLOCK) { + setKey = nonStrict; + deleteKey = strict; + } else { + setKey = strict; + deleteKey = nonStrict; + } + + if (!uriToPerm[setKey]) { + uriToPerm[setKey] = {}; + } + + let expireTimeout = uriToPerm[setKey][id]?.expireTimeout; + let previousState = uriToPerm[setKey][id]?.state; + // If overwriting a permission state. We need to cancel the old timeout. + if (expireTimeout) { + lazy.clearTimeout(expireTimeout); + } + // Construct the new timeout to remove the permission once it has expired. + expireTimeout = lazy.setTimeout(() => { + let entryBrowser = entry.browser.get(); + // Exit early if the browser is no longer alive when we get the timeout + // callback. + if (!entryBrowser || !uriToPerm[setKey]) { + return; + } + delete uriToPerm[setKey][id]; + // Notify SitePermissions that a temporary permission has expired. + // Get the browser the permission is currently set for. If this.copy was + // used this browser is different from the original one passed above. + expireCallback(entryBrowser); + }, expireTimeMS); + uriToPerm[setKey][id] = { + expireTimeout, + state, + }; + + // If we set a permission state for a origin we need to reset the old state + // which may be set for baseDomain and vice versa. An individual permission + // must only ever be keyed by either origin or baseDomain. + let permissions = uriToPerm[deleteKey]; + if (permissions) { + expireTimeout = permissions[id]?.expireTimeout; + if (expireTimeout) { + lazy.clearTimeout(expireTimeout); + } + delete permissions[id]; + } + + return state != previousState; + }, + + /** + * Removes a permission with the specified id for the specified browser. + * @returns {boolean} whether the permission was removed. + */ + remove(browser, id) { + if ( + !browser || + !SitePermissions.isSupportedPrincipal(browser.contentPrincipal) || + !this._stateByBrowser.has(browser) + ) { + return false; + } + // Permission can be stored by any of the two keys (strict and non-strict). + // getKeysFromURI can throw. We let the caller handle the exception. + let { strict, nonStrict } = this._getKeysFromPrincipal( + browser.contentPrincipal + ); + let { uriToPerm } = this._stateByBrowser.get(browser); + for (let key of [nonStrict, strict]) { + if (uriToPerm[key]?.[id] != null) { + let { expireTimeout } = uriToPerm[key][id]; + if (expireTimeout) { + lazy.clearTimeout(expireTimeout); + } + delete uriToPerm[key][id]; + // Individual permissions can only ever be keyed either strict or + // non-strict. If we find the permission via the first key run we can + // return early. + return true; + } + } + return false; + }, + + // Gets a permission with the specified id for the specified browser. + get(browser, id) { + if ( + !browser || + !browser.contentPrincipal || + !SitePermissions.isSupportedPrincipal(browser.contentPrincipal) || + !this._stateByBrowser.has(browser) + ) { + return null; + } + let { uriToPerm } = this._stateByBrowser.get(browser); + + let { strict, nonStrict } = this._getKeysFromPrincipal( + browser.contentPrincipal + ); + for (let key of [nonStrict, strict]) { + if (uriToPerm[key]) { + let permission = uriToPerm[key][id]; + if (permission) { + return { + id, + state: permission.state, + scope: SitePermissions.SCOPE_TEMPORARY, + }; + } + } + } + return null; + }, + + // Gets all permissions for the specified browser. + // Note that only permissions that apply to the current URI + // of the passed browser element will be returned. + getAll(browser) { + let permissions = []; + if ( + !SitePermissions.isSupportedPrincipal(browser.contentPrincipal) || + !this._stateByBrowser.has(browser) + ) { + return permissions; + } + let { uriToPerm } = this._stateByBrowser.get(browser); + + let { strict, nonStrict } = this._getKeysFromPrincipal( + browser.contentPrincipal + ); + for (let key of [nonStrict, strict]) { + if (uriToPerm[key]) { + let perms = uriToPerm[key]; + for (let id of Object.keys(perms)) { + let permission = perms[id]; + if (permission) { + permissions.push({ + id, + state: permission.state, + scope: SitePermissions.SCOPE_TEMPORARY, + }); + } + } + } + } + + return permissions; + }, + + // Clears all permissions for the specified browser. + // Unlike other methods, this does NOT clear only for + // the currentURI but the whole browser state. + + /** + * Clear temporary permissions for the specified browser. Unlike other + * methods, this does NOT clear only for the currentURI but the whole browser + * state. + * @param {Browser} browser - Browser to clear permissions for. + * @param {Number} [filterState] - Only clear permissions with the given state + * value. Defaults to all permissions. + */ + clear(browser, filterState = null) { + let entry = this._stateByBrowser.get(browser); + if (!entry?.uriToPerm) { + return; + } + + let { uriToPerm } = entry; + Object.entries(uriToPerm).forEach(([uriKey, permissions]) => { + Object.entries(permissions).forEach( + ([permId, { state, expireTimeout }]) => { + // We need to explicitly check for null or undefined here, because the + // permission state may be 0. + if (filterState != null) { + if (state != filterState) { + // Skip permission entry if it doesn't match the filter. + return; + } + delete permissions[permId]; + } + // For the clear-all case we remove the entire browser entry, so we + // only need to clear the timeouts. + if (!expireTimeout) { + return; + } + lazy.clearTimeout(expireTimeout); + } + ); + // If there are no more permissions, remove the entry from the URI map. + if (filterState != null && !Object.keys(permissions).length) { + delete uriToPerm[uriKey]; + } + }); + + // We're either clearing all permissions or only the permissions with state + // == filterState. If we have a filter, we can only clean up the browser if + // there are no permission entries left in the map. + if (filterState == null || !Object.keys(uriToPerm).length) { + this._stateByBrowser.delete(browser); + } + }, + + // Copies the temporary permission state of one browser + // into a new entry for the other browser. + copy(browser, newBrowser) { + let entry = this._stateByBrowser.get(browser); + if (entry) { + entry.browser = Cu.getWeakReference(newBrowser); + this._stateByBrowser.set(newBrowser, entry); + } + }, +}; + +// This hold a flag per browser to indicate whether we should show the +// user a notification as a permission has been requested that has been +// blocked globally. We only want to notify the user in the case that +// they actually requested the permission within the current page load +// so will clear the flag on navigation. +const GloballyBlockedPermissions = { + _stateByBrowser: new WeakMap(), + + /** + * @returns {boolean} whether the permission was removed. + */ + set(browser, id) { + if (!this._stateByBrowser.has(browser)) { + this._stateByBrowser.set(browser, {}); + } + let entry = this._stateByBrowser.get(browser); + let origin = browser.contentPrincipal.origin; + if (!entry[origin]) { + entry[origin] = {}; + } + + if (entry[origin][id]) { + return false; + } + entry[origin][id] = true; + + // Clear the flag and remove the listener once the user has navigated. + // WebProgress will report various things including hashchanges to us, the + // navigation we care about is either leaving the current page or reloading. + let { prePath } = browser.currentURI; + browser.addProgressListener( + { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + let hasLeftPage = + aLocation.prePath != prePath || + !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + let isReload = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD + ); + + if (aWebProgress.isTopLevel && (hasLeftPage || isReload)) { + GloballyBlockedPermissions.remove(browser, id, origin); + browser.removeProgressListener(this); + } + }, + }, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + return true; + }, + + // Removes a permission with the specified id for the specified browser. + remove(browser, id, origin = null) { + let entry = this._stateByBrowser.get(browser); + if (!origin) { + origin = browser.contentPrincipal.origin; + } + if (entry && entry[origin]) { + delete entry[origin][id]; + } + }, + + // Gets all permissions for the specified browser. + // Note that only permissions that apply to the current URI + // of the passed browser element will be returned. + getAll(browser) { + let permissions = []; + let entry = this._stateByBrowser.get(browser); + let origin = browser.contentPrincipal.origin; + if (entry && entry[origin]) { + let timeStamps = entry[origin]; + for (let id of Object.keys(timeStamps)) { + permissions.push({ + id, + state: gPermissions.get(id).getDefault(), + scope: SitePermissions.SCOPE_GLOBAL, + }); + } + } + return permissions; + }, + + // Copies the globally blocked permission state of one browser + // into a new entry for the other browser. + copy(browser, newBrowser) { + let entry = this._stateByBrowser.get(browser); + if (entry) { + this._stateByBrowser.set(newBrowser, entry); + } + }, +}; + +/** + * A module to manage permanent and temporary permissions + * by URI and browser. + * + * Some methods have the side effect of dispatching a "PermissionStateChange" + * event on changes to temporary permissions, as mentioned in the respective docs. + */ +export var SitePermissions = { + // Permission states. + UNKNOWN: Services.perms.UNKNOWN_ACTION, + ALLOW: Services.perms.ALLOW_ACTION, + BLOCK: Services.perms.DENY_ACTION, + PROMPT: Services.perms.PROMPT_ACTION, + ALLOW_COOKIES_FOR_SESSION: Ci.nsICookiePermission.ACCESS_SESSION, + AUTOPLAY_BLOCKED_ALL: Ci.nsIAutoplay.BLOCKED_ALL, + + // Permission scopes. + SCOPE_REQUEST: "{SitePermissions.SCOPE_REQUEST}", + SCOPE_TEMPORARY: "{SitePermissions.SCOPE_TEMPORARY}", + SCOPE_SESSION: "{SitePermissions.SCOPE_SESSION}", + SCOPE_PERSISTENT: "{SitePermissions.SCOPE_PERSISTENT}", + SCOPE_POLICY: "{SitePermissions.SCOPE_POLICY}", + SCOPE_GLOBAL: "{SitePermissions.SCOPE_GLOBAL}", + + // The delimiter used for double keyed permissions. + // For example: open-protocol-handler^irc + PERM_KEY_DELIMITER: "^", + + _permissionsArray: null, + _defaultPrefBranch: Services.prefs.getBranch("permissions.default."), + + // For testing use only. + _temporaryPermissions: TemporaryPermissions, + + /** + * Gets all custom permissions for a given principal. + * Install addon permission is excluded, check bug 1303108. + * + * @return {Array} a list of objects with the keys: + * - id: the permissionId of the permission + * - scope: the scope of the permission (e.g. SitePermissions.SCOPE_TEMPORARY) + * - state: a constant representing the current permission state + * (e.g. SitePermissions.ALLOW) + */ + getAllByPrincipal(principal) { + if (!principal) { + throw new Error("principal argument cannot be null."); + } + if (!this.isSupportedPrincipal(principal)) { + return []; + } + + // Get all permissions from the permission manager by principal, excluding + // the ones set to be disabled. + let permissions = Services.perms + .getAllForPrincipal(principal) + .filter(permission => { + let entry = gPermissions.get(permission.type); + if (!entry || entry.disabled) { + return false; + } + let type = entry.id; + + /* Hide persistent storage permission when extension principal + * have WebExtensions-unlimitedStorage permission. */ + if ( + type == "persistent-storage" && + SitePermissions.getForPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ).state == SitePermissions.ALLOW + ) { + return false; + } + + return true; + }); + + return permissions.map(permission => { + let scope = this.SCOPE_PERSISTENT; + if (permission.expireType == Services.perms.EXPIRE_SESSION) { + scope = this.SCOPE_SESSION; + } else if (permission.expireType == Services.perms.EXPIRE_POLICY) { + scope = this.SCOPE_POLICY; + } + + return { + id: permission.type, + scope, + state: permission.capability, + }; + }); + }, + + /** + * Returns all custom permissions for a given browser. + * + * To receive a more detailed, albeit less performant listing see + * SitePermissions.getAllPermissionDetailsForBrowser(). + * + * @param {Browser} browser + * The browser to fetch permission for. + * + * @return {Array} a list of objects with the keys: + * - id: the permissionId of the permission + * - state: a constant representing the current permission state + * (e.g. SitePermissions.ALLOW) + * - scope: a constant representing how long the permission will + * be kept. + */ + getAllForBrowser(browser) { + let permissions = {}; + + for (let permission of TemporaryPermissions.getAll(browser)) { + permission.scope = this.SCOPE_TEMPORARY; + permissions[permission.id] = permission; + } + + for (let permission of GloballyBlockedPermissions.getAll(browser)) { + permissions[permission.id] = permission; + } + + for (let permission of this.getAllByPrincipal(browser.contentPrincipal)) { + permissions[permission.id] = permission; + } + + return Object.values(permissions); + }, + + /** + * Returns a list of objects with detailed information on all permissions + * that are currently set for the given browser. + * + * @param {Browser} browser + * The browser to fetch permission for. + * + * @return {Array<Object>} a list of objects with the keys: + * - id: the permissionID of the permission + * - state: a constant representing the current permission state + * (e.g. SitePermissions.ALLOW) + * - scope: a constant representing how long the permission will + * be kept. + * - label: the localized label, or null if none is available. + */ + getAllPermissionDetailsForBrowser(browser) { + return this.getAllForBrowser(browser).map(({ id, scope, state }) => ({ + id, + scope, + state, + label: this.getPermissionLabel(id), + })); + }, + + /** + * Checks whether a UI for managing permissions should be exposed for a given + * principal. + * + * @param {nsIPrincipal} principal + * The principal to check. + * + * @return {boolean} if the principal is supported. + */ + isSupportedPrincipal(principal) { + if (!principal) { + return false; + } + if (!(principal instanceof Ci.nsIPrincipal)) { + throw new Error( + "Argument passed as principal is not an instance of Ci.nsIPrincipal" + ); + } + return this.isSupportedScheme(principal.scheme); + }, + + /** + * Checks whether we support managing permissions for a specific scheme. + * @param {string} scheme - Scheme to test. + * @returns {boolean} Whether the scheme is supported. + */ + isSupportedScheme(scheme) { + return ["http", "https", "moz-extension", "file"].includes(scheme); + }, + + /** + * Gets an array of all permission IDs. + * + * @return {Array<String>} an array of all permission IDs. + */ + listPermissions() { + if (this._permissionsArray === null) { + this._permissionsArray = gPermissions.getEnabledPermissions(); + } + return this._permissionsArray; + }, + + /** + * Test whether a permission is managed by SitePermissions. + * @param {string} type - Permission type. + * @returns {boolean} + */ + isSitePermission(type) { + return gPermissions.has(type); + }, + + /** + * Called when a preference changes its value. + * + * @param {string} data + * The last argument passed to the preference change observer + * @param {string} previous + * The previous value of the preference + * @param {string} latest + * The latest value of the preference + */ + invalidatePermissionList(data, previous, latest) { + // Ensure that listPermissions() will reconstruct its return value the next + // time it's called. + this._permissionsArray = null; + }, + + /** + * Returns an array of permission states to be exposed to the user for a + * permission with the given ID. + * + * @param {string} permissionID + * The ID to get permission states for. + * + * @return {Array<SitePermissions state>} an array of all permission states. + */ + getAvailableStates(permissionID) { + if ( + gPermissions.has(permissionID) && + gPermissions.get(permissionID).states + ) { + return gPermissions.get(permissionID).states; + } + + /* Since the permissions we are dealing with have adopted the convention + * of treating UNKNOWN == PROMPT, we only include one of either UNKNOWN + * or PROMPT in this list, to avoid duplicating states. */ + if (this.getDefault(permissionID) == this.UNKNOWN) { + return [ + SitePermissions.UNKNOWN, + SitePermissions.ALLOW, + SitePermissions.BLOCK, + ]; + } + + return [ + SitePermissions.PROMPT, + SitePermissions.ALLOW, + SitePermissions.BLOCK, + ]; + }, + + /** + * Returns the default state of a particular permission. + * + * @param {string} permissionID + * The ID to get the default for. + * + * @return {SitePermissions.state} the default state. + */ + getDefault(permissionID) { + // If the permission has custom logic for getting its default value, + // try that first. + if ( + gPermissions.has(permissionID) && + gPermissions.get(permissionID).getDefault + ) { + return gPermissions.get(permissionID).getDefault(); + } + + // Otherwise try to get the default preference for that permission. + return this._defaultPrefBranch.getIntPref(permissionID, this.UNKNOWN); + }, + + /** + * Set the default state of a particular permission. + * + * @param {string} permissionID + * The ID to set the default for. + * + * @param {string} state + * The state to set. + */ + setDefault(permissionID, state) { + if ( + gPermissions.has(permissionID) && + gPermissions.get(permissionID).setDefault + ) { + return gPermissions.get(permissionID).setDefault(state); + } + let key = "permissions.default." + permissionID; + return Services.prefs.setIntPref(key, state); + }, + + /** + * Returns the state and scope of a particular permission for a given principal. + * + * This method will NOT dispatch a "PermissionStateChange" event on the specified + * browser if a temporary permission was removed because it has expired. + * + * @param {nsIPrincipal} principal + * The principal to check. + * @param {String} permissionID + * The id of the permission. + * @param {Browser} [browser] The browser object to check for temporary + * permissions. + * + * @return {Object} an object with the keys: + * - state: The current state of the permission + * (e.g. SitePermissions.ALLOW) + * - scope: The scope of the permission + * (e.g. SitePermissions.SCOPE_PERSISTENT) + */ + getForPrincipal(principal, permissionID, browser) { + if (!principal && !browser) { + throw new Error( + "Atleast one of the arguments, either principal or browser should not be null." + ); + } + let defaultState = this.getDefault(permissionID); + let result = { state: defaultState, scope: this.SCOPE_PERSISTENT }; + if (this.isSupportedPrincipal(principal)) { + let permission = null; + if ( + gPermissions.has(permissionID) && + gPermissions.get(permissionID).exactHostMatch + ) { + permission = Services.perms.getPermissionObject( + principal, + permissionID, + true + ); + } else { + permission = Services.perms.getPermissionObject( + principal, + permissionID, + false + ); + } + + if (permission) { + result.state = permission.capability; + if (permission.expireType == Services.perms.EXPIRE_SESSION) { + result.scope = this.SCOPE_SESSION; + } else if (permission.expireType == Services.perms.EXPIRE_POLICY) { + result.scope = this.SCOPE_POLICY; + } + } + } + + if (result.state == defaultState) { + // If there's no persistent permission saved, check if we have something + // set temporarily. + let value = TemporaryPermissions.get(browser, permissionID); + + if (value) { + result.state = value.state; + result.scope = this.SCOPE_TEMPORARY; + } + } + + return result; + }, + + /** + * Sets the state of a particular permission for a given principal or browser. + * This method will dispatch a "PermissionStateChange" event on the specified + * browser if a temporary permission was set + * + * @param {nsIPrincipal} [principal] The principal to set the permission for. + * When setting temporary permissions passing a principal is optional. + * If the principal is still passed here it takes precedence over the + * browser's contentPrincipal for permission keying. This can be + * helpful in situations where the browser has already navigated away + * from a site you want to set a permission for. + * @param {String} permissionID The id of the permission. + * @param {SitePermissions state} state The state of the permission. + * @param {SitePermissions scope} [scope] The scope of the permission. + * Defaults to SCOPE_PERSISTENT. + * @param {Browser} [browser] The browser object to set temporary permissions + * on. This needs to be provided if the scope is SCOPE_TEMPORARY! + * @param {number} [expireTimeMS] If setting a temporary permission, how many + * milliseconds it should be valid for. The default is controlled by + * the 'privacy.temporary_permission_expire_time_ms' pref. + */ + setForPrincipal( + principal, + permissionID, + state, + scope = this.SCOPE_PERSISTENT, + browser = null, + expireTimeMS = SitePermissions.temporaryPermissionExpireTime + ) { + if (!principal && !browser) { + throw new Error( + "Atleast one of the arguments, either principal or browser should not be null." + ); + } + if (scope == this.SCOPE_GLOBAL && state == this.BLOCK) { + if (GloballyBlockedPermissions.set(browser, permissionID)) { + browser.dispatchEvent( + new browser.ownerGlobal.CustomEvent("PermissionStateChange") + ); + } + return; + } + + if (state == this.UNKNOWN || state == this.getDefault(permissionID)) { + // Because they are controlled by two prefs with many states that do not + // correspond to the classical ALLOW/DENY/PROMPT model, we want to always + // allow the user to add exceptions to their cookie rules without removing them. + if (permissionID != "cookie") { + this.removeFromPrincipal(principal, permissionID, browser); + return; + } + } + + if (state == this.ALLOW_COOKIES_FOR_SESSION && permissionID != "cookie") { + throw new Error( + "ALLOW_COOKIES_FOR_SESSION can only be set on the cookie permission" + ); + } + + // Save temporary permissions. + if (scope == this.SCOPE_TEMPORARY) { + if (!browser) { + throw new Error( + "TEMPORARY scoped permissions require a browser object" + ); + } + if (!Number.isInteger(expireTimeMS) || expireTimeMS <= 0) { + throw new Error("expireTime must be a positive integer"); + } + + if ( + TemporaryPermissions.set( + browser, + permissionID, + state, + expireTimeMS, + principal ?? browser.contentPrincipal, + // On permission expiry + origBrowser => { + if (!origBrowser.ownerGlobal) { + return; + } + origBrowser.dispatchEvent( + new origBrowser.ownerGlobal.CustomEvent("PermissionStateChange") + ); + } + ) + ) { + browser.dispatchEvent( + new browser.ownerGlobal.CustomEvent("PermissionStateChange") + ); + } + } else if (this.isSupportedPrincipal(principal)) { + let perms_scope = Services.perms.EXPIRE_NEVER; + if (scope == this.SCOPE_SESSION) { + perms_scope = Services.perms.EXPIRE_SESSION; + } else if (scope == this.SCOPE_POLICY) { + perms_scope = Services.perms.EXPIRE_POLICY; + } + + Services.perms.addFromPrincipal( + principal, + permissionID, + state, + perms_scope + ); + } + }, + + /** + * Removes the saved state of a particular permission for a given principal and/or browser. + * This method will dispatch a "PermissionStateChange" event on the specified + * browser if a temporary permission was removed. + * + * @param {nsIPrincipal} principal + * The principal to remove the permission for. + * @param {String} permissionID + * The id of the permission. + * @param {Browser} browser (optional) + * The browser object to remove temporary permissions on. + */ + removeFromPrincipal(principal, permissionID, browser) { + if (!principal && !browser) { + throw new Error( + "Atleast one of the arguments, either principal or browser should not be null." + ); + } + if (this.isSupportedPrincipal(principal)) { + Services.perms.removeFromPrincipal(principal, permissionID); + } + + // TemporaryPermissions.get() deletes expired permissions automatically, + // if it hasn't expired, remove it explicitly. + if (TemporaryPermissions.remove(browser, permissionID)) { + // Send a PermissionStateChange event only if the permission hasn't expired. + browser.dispatchEvent( + new browser.ownerGlobal.CustomEvent("PermissionStateChange") + ); + } + }, + + /** + * Clears all block permissions that were temporarily saved. + * + * @param {Browser} browser + * The browser object to clear. + */ + clearTemporaryBlockPermissions(browser) { + TemporaryPermissions.clear(browser, SitePermissions.BLOCK); + }, + + /** + * Copy all permissions that were temporarily saved on one + * browser object to a new browser. + * + * @param {Browser} browser + * The browser object to copy from. + * @param {Browser} newBrowser + * The browser object to copy to. + */ + copyTemporaryPermissions(browser, newBrowser) { + TemporaryPermissions.copy(browser, newBrowser); + GloballyBlockedPermissions.copy(browser, newBrowser); + }, + + /** + * Returns the localized label for the permission with the given ID, to be + * used in a UI for managing permissions. + * If a permission is double keyed (has an additional key in the ID), the + * second key is split off and supplied to the string formatter as a variable. + * + * @param {string} permissionID + * The permission to get the label for. May include second key. + * + * @return {String} the localized label or null if none is available. + */ + getPermissionLabel(permissionID) { + let [id, key] = permissionID.split(this.PERM_KEY_DELIMITER); + if (!gPermissions.has(id)) { + // Permission can't be found. + return null; + } + if ( + "labelID" in gPermissions.get(id) && + gPermissions.get(id).labelID === null + ) { + // Permission doesn't support having a label. + return null; + } + if (id == "3rdPartyStorage" || id == "3rdPartyFrameStorage") { + // The key is the 3rd party origin or site, which we use for the label. + return key; + } + let labelID = gPermissions.get(id).labelID || id; + return gStringBundle.formatStringFromName(`permission.${labelID}.label`, [ + key, + ]); + }, + + /** + * Returns the localized label for the given permission state, to be used in + * a UI for managing permissions. + * + * @param {string} permissionID + * The permission to get the label for. + * + * @param {SitePermissions state} state + * The state to get the label for. + * + * @return {String|null} the localized label or null if an + * unknown state was passed. + */ + getMultichoiceStateLabel(permissionID, state) { + // If the permission has custom logic for getting its default value, + // try that first. + if ( + gPermissions.has(permissionID) && + gPermissions.get(permissionID).getMultichoiceStateLabel + ) { + return gPermissions.get(permissionID).getMultichoiceStateLabel(state); + } + + switch (state) { + case this.UNKNOWN: + case this.PROMPT: + return gStringBundle.GetStringFromName("state.multichoice.alwaysAsk"); + case this.ALLOW: + return gStringBundle.GetStringFromName("state.multichoice.allow"); + case this.ALLOW_COOKIES_FOR_SESSION: + return gStringBundle.GetStringFromName( + "state.multichoice.allowForSession" + ); + case this.BLOCK: + return gStringBundle.GetStringFromName("state.multichoice.block"); + default: + return null; + } + }, + + /** + * Returns the localized label for a permission's current state. + * + * @param {SitePermissions state} state + * The state to get the label for. + * @param {string} id + * The permission to get the state label for. + * @param {SitePermissions scope} scope (optional) + * The scope to get the label for. + * + * @return {String|null} the localized label or null if an + * unknown state was passed. + */ + getCurrentStateLabel(state, id, scope = null) { + switch (state) { + case this.PROMPT: + return gStringBundle.GetStringFromName("state.current.prompt"); + case this.ALLOW: + if ( + scope && + scope != this.SCOPE_PERSISTENT && + scope != this.SCOPE_POLICY + ) { + return gStringBundle.GetStringFromName( + "state.current.allowedTemporarily" + ); + } + return gStringBundle.GetStringFromName("state.current.allowed"); + case this.ALLOW_COOKIES_FOR_SESSION: + return gStringBundle.GetStringFromName( + "state.current.allowedForSession" + ); + case this.BLOCK: + if ( + scope && + scope != this.SCOPE_PERSISTENT && + scope != this.SCOPE_POLICY && + scope != this.SCOPE_GLOBAL + ) { + return gStringBundle.GetStringFromName( + "state.current.blockedTemporarily" + ); + } + return gStringBundle.GetStringFromName("state.current.blocked"); + default: + return null; + } + }, +}; + +let gPermissions = { + _getId(type) { + // Split off second key (if it exists). + let [id] = type.split(SitePermissions.PERM_KEY_DELIMITER); + return id; + }, + + has(type) { + return this._getId(type) in this._permissions; + }, + + get(type) { + let id = this._getId(type); + let perm = this._permissions[id]; + if (perm) { + perm.id = id; + } + return perm; + }, + + getEnabledPermissions() { + return Object.keys(this._permissions).filter( + id => !this._permissions[id].disabled + ); + }, + + /* Holds permission ID => options pairs. + * + * Supported options: + * + * - exactHostMatch + * Allows sub domains to have their own permissions. + * Defaults to false. + * + * - getDefault + * Called to get the permission's default state. + * Defaults to UNKNOWN, indicating that the user will be asked each time + * a page asks for that permissions. + * + * - labelID + * Use the given ID instead of the permission name for looking up strings. + * e.g. "desktop-notification2" to use permission.desktop-notification2.label + * + * - states + * Array of permission states to be exposed to the user. + * Defaults to ALLOW, BLOCK and the default state (see getDefault). + * + * - getMultichoiceStateLabel + * Optional method to overwrite SitePermissions#getMultichoiceStateLabel with custom label logic. + */ + _permissions: { + "autoplay-media": { + exactHostMatch: true, + getDefault() { + let pref = Services.prefs.getIntPref( + "media.autoplay.default", + Ci.nsIAutoplay.BLOCKED + ); + if (pref == Ci.nsIAutoplay.ALLOWED) { + return SitePermissions.ALLOW; + } + if (pref == Ci.nsIAutoplay.BLOCKED_ALL) { + return SitePermissions.AUTOPLAY_BLOCKED_ALL; + } + return SitePermissions.BLOCK; + }, + setDefault(value) { + let prefValue = Ci.nsIAutoplay.BLOCKED; + if (value == SitePermissions.ALLOW) { + prefValue = Ci.nsIAutoplay.ALLOWED; + } else if (value == SitePermissions.AUTOPLAY_BLOCKED_ALL) { + prefValue = Ci.nsIAutoplay.BLOCKED_ALL; + } + Services.prefs.setIntPref("media.autoplay.default", prefValue); + }, + labelID: "autoplay", + states: [ + SitePermissions.ALLOW, + SitePermissions.BLOCK, + SitePermissions.AUTOPLAY_BLOCKED_ALL, + ], + getMultichoiceStateLabel(state) { + switch (state) { + case SitePermissions.AUTOPLAY_BLOCKED_ALL: + return gStringBundle.GetStringFromName( + "state.multichoice.autoplayblockall" + ); + case SitePermissions.BLOCK: + return gStringBundle.GetStringFromName( + "state.multichoice.autoplayblock" + ); + case SitePermissions.ALLOW: + return gStringBundle.GetStringFromName( + "state.multichoice.autoplayallow" + ); + } + throw new Error(`Unknown state: ${state}`); + }, + }, + + cookie: { + states: [ + SitePermissions.ALLOW, + SitePermissions.ALLOW_COOKIES_FOR_SESSION, + SitePermissions.BLOCK, + ], + getDefault() { + if ( + Services.cookies.getCookieBehavior(false) == + Ci.nsICookieService.BEHAVIOR_REJECT + ) { + return SitePermissions.BLOCK; + } + + return SitePermissions.ALLOW; + }, + }, + + "desktop-notification": { + exactHostMatch: true, + labelID: "desktop-notification3", + }, + + camera: { + exactHostMatch: true, + }, + + microphone: { + exactHostMatch: true, + }, + + screen: { + exactHostMatch: true, + states: [SitePermissions.UNKNOWN, SitePermissions.BLOCK], + }, + + speaker: { + exactHostMatch: true, + states: [SitePermissions.UNKNOWN, SitePermissions.BLOCK], + get disabled() { + return !SitePermissions.setSinkIdEnabled; + }, + }, + + popup: { + getDefault() { + return Services.prefs.getBoolPref("dom.disable_open_during_load") + ? SitePermissions.BLOCK + : SitePermissions.ALLOW; + }, + states: [SitePermissions.ALLOW, SitePermissions.BLOCK], + }, + + install: { + getDefault() { + return Services.prefs.getBoolPref("xpinstall.whitelist.required") + ? SitePermissions.UNKNOWN + : SitePermissions.ALLOW; + }, + }, + + geo: { + exactHostMatch: true, + }, + + "open-protocol-handler": { + labelID: "open-protocol-handler", + exactHostMatch: true, + states: [SitePermissions.UNKNOWN, SitePermissions.ALLOW], + get disabled() { + return !SitePermissions.openProtoPermissionEnabled; + }, + }, + + xr: { + exactHostMatch: true, + }, + + "focus-tab-by-prompt": { + exactHostMatch: true, + states: [SitePermissions.UNKNOWN, SitePermissions.ALLOW], + }, + "persistent-storage": { + exactHostMatch: true, + }, + + shortcuts: { + states: [SitePermissions.ALLOW, SitePermissions.BLOCK], + }, + + canvas: { + get disabled() { + return !SitePermissions.resistFingerprinting; + }, + }, + + midi: { + exactHostMatch: true, + get disabled() { + return !SitePermissions.midiPermissionEnabled; + }, + }, + + "midi-sysex": { + exactHostMatch: true, + get disabled() { + return !SitePermissions.midiPermissionEnabled; + }, + }, + + "storage-access": { + labelID: null, + getDefault() { + return SitePermissions.UNKNOWN; + }, + }, + + "3rdPartyStorage": {}, + "3rdPartyFrameStorage": {}, + }, +}; + +SitePermissions.midiPermissionEnabled = Services.prefs.getBoolPref( + "dom.webmidi.enabled" +); + +XPCOMUtils.defineLazyPreferenceGetter( + SitePermissions, + "temporaryPermissionExpireTime", + "privacy.temporary_permission_expire_time_ms", + 3600 * 1000 +); +XPCOMUtils.defineLazyPreferenceGetter( + SitePermissions, + "setSinkIdEnabled", + "media.setsinkid.enabled", + false, + SitePermissions.invalidatePermissionList.bind(SitePermissions) +); +XPCOMUtils.defineLazyPreferenceGetter( + SitePermissions, + "resistFingerprinting", + "privacy.resistFingerprinting", + false, + SitePermissions.invalidatePermissionList.bind(SitePermissions) +); +XPCOMUtils.defineLazyPreferenceGetter( + SitePermissions, + "openProtoPermissionEnabled", + "security.external_protocol_requires_permission", + true, + SitePermissions.invalidatePermissionList.bind(SitePermissions) +); |