Source code

Revision control

Copy as Markdown

Other Tools

/* 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 { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
import {
GATED_PERMISSIONS,
SITEPERMS_ADDON_PROVIDER_PREF,
SITEPERMS_ADDON_TYPE,
isGatedPermissionType,
isKnownPublicSuffix,
isPrincipalInSitePermissionsBlocklist,
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
ChromeUtils.defineLazyGetter(
lazy,
"addonsBundle",
() => new Localization(["toolkit/about/aboutAddons.ftl"], true)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"SITEPERMS_ADDON_PROVIDER_ENABLED",
SITEPERMS_ADDON_PROVIDER_PREF,
false
);
const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org";
// Generate a per-session random salt, which is then used to generate
// per-siteOrigin hashed strings used as the addon id in SitePermsAddonWrapper constructor
// (expected to be matching new addon id generated for the same siteOrigin during
// the same browsing session and different ones in new browsing sessions).
//
// NOTE: `generateSalt` is exported for testing purpose, should not be
// used outside of tests.
let SALT;
export function generateSalt() {
// Throw if we're not in test and SALT is already defined
if (
typeof SALT !== "undefined" &&
!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
) {
throw new Error("This should only be called from XPCShell tests");
}
SALT = crypto.getRandomValues(new Uint8Array(12)).join("");
}
function getSalt() {
if (!SALT) {
generateSalt();
}
return SALT;
}
class SitePermsAddonWrapper {
// An array of nsIPermission granted for the siteOrigin.
// We can't use a Set as handlePermissionChange might be called with different
// nsIPermission instance for the same permission (in the generic sense)
#permissions = [];
// This will be set to true in the `uninstall` method to recognize when a perm-changed notification
// is actually triggered by the SitePermsAddonWrapper uninstall method itself.
isUninstalling = false;
/**
* @param {string} siteOriginNoSuffix: The origin this addon is installed for
* WITHOUT the suffix generated from the
* origin attributes (see:
* nsIPrincipal.siteOriginNoSuffix).
* @param {Array<nsIPermission>} permissions: An array of the initial
* permissions the user granted
* for the addon origin.
*/
constructor(siteOriginNoSuffix, permissions = []) {
this.siteOrigin = siteOriginNoSuffix;
this.principal =
Services.scriptSecurityManager.createContentPrincipalFromOrigin(
this.siteOrigin
);
// Use a template string for the concat in case `siteOrigin` isn't a string.
const saltedValue = `${this.siteOrigin}${getSalt()}`;
this.id = `${computeSha256HashAsString(
saltedValue
)}${SITEPERMS_ADDON_ID_SUFFIX}`;
for (const perm of permissions) {
this.#permissions.push(perm);
}
}
get isUninstalled() {
return this.#permissions.length === 0;
}
/**
* Returns the list of gated permissions types granted for the instance's origin
*
* @return {Array<String>}
*/
get sitePermissions() {
return Array.from(new Set(this.#permissions.map(perm => perm.type)));
}
/**
* Update #permissions, and calls `uninstall` if there are no remaining gated permissions
* granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated
* permission.
*
* @param {nsIPermission} permission: The permission being added/removed
* @param {String} action: The action perm-changed notifies us about
*/
handlePermissionChange(permission, action) {
if (action == "added") {
this.#permissions.push(permission);
} else if (action == "deleted") {
// We want to remove the registered permission for the right principal (looking into originSuffix so we
// can unregister revoked permission on a specific context, private window, ...).
this.#permissions = this.#permissions.filter(
perm =>
!(
perm.type == permission.type &&
perm.principal.originSuffix === permission.principal.originSuffix
)
);
if (this.#permissions.length === 0) {
this.uninstall();
}
}
}
get type() {
return SITEPERMS_ADDON_TYPE;
}
get name() {
return lazy.addonsBundle.formatValueSync("addon-sitepermission-host", {
host: this.principal.host,
});
}
get creator() {
return undefined;
}
get homepageURL() {
return undefined;
}
get description() {
return undefined;
}
get fullDescription() {
return undefined;
}
get version() {
// We consider the previous implementation attempt (signed addons) to be the initial version,
// hence the 2.0 for this approach.
return "2.0";
}
get updateDate() {
return undefined;
}
get isActive() {
return true;
}
get appDisabled() {
return false;
}
get userDisabled() {
return false;
}
set userDisabled(aVal) {}
get size() {
return 0;
}
async updateBlocklistState() {}
get blocklistState() {
return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
}
get scope() {
return lazy.AddonManager.SCOPE_APPLICATION;
}
get pendingOperations() {
return lazy.AddonManager.PENDING_NONE;
}
get operationsRequiringRestart() {
return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
}
get permissions() {
// The addon only supports PERM_CAN_UNINSTALL and no other AOM permission.
return lazy.AddonManager.PERM_CAN_UNINSTALL;
}
get signedState() {
// Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string
return lazy.AddonManager.SIGNEDSTATE_MISSING;
}
async enable() {}
async disable() {}
/**
* Uninstall the addon, calling AddonManager hooks and removing all granted permissions.
*
* @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal.
*/
async uninstall() {
if (this.isUninstalling) {
return;
}
try {
this.isUninstalling = true;
lazy.AddonManagerPrivate.callAddonListeners(
"onUninstalling",
this,
false
);
const permissions = [...this.#permissions];
for (const permission of permissions) {
try {
Services.perms.removeFromPrincipal(
permission.principal,
permission.type
);
// Only remove the permission from the array if it was successfully removed from the principal
this.#permissions.splice(this.#permissions.indexOf(permission), 1);
} catch (err) {
Cu.reportError(err);
}
}
lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
} finally {
this.isUninstalling = false;
}
}
get isCompatible() {
return true;
}
get isPlatformCompatible() {
return true;
}
get providesUpdatesSecurely() {
return true;
}
get foreignInstall() {
return false;
}
get installTelemetryInfo() {
return { source: "siteperm-addon-provider", method: "synthetic-install" };
}
isCompatibleWith() {
return true;
}
}
class SitePermsAddonInstalling extends SitePermsAddonWrapper {
/**
* @param {string} siteOriginNoSuffix: The origin this addon is installed
* for, WITHOUT the suffix generated from
* the origin attributes (see:
* nsIPrincipal.siteOriginNoSuffix).
* @param {SitePermsAddonInstall} install: The SitePermsAddonInstall instance
* calling this constructor.
*/
constructor(siteOriginNoSuffix, install) {
// SitePermsAddonWrapper expect an array of nsIPermission as its second parameter.
// Since we don't have a proper permission here, we pass an object with the properties
// being used in the class.
const permission = {
principal: install.principal,
type: install.newSitePerm,
};
super(siteOriginNoSuffix, [permission]);
}
get existingAddon() {
return SitePermsAddonProvider.wrappersMapByOrigin.get(this.siteOrigin);
}
uninstall() {
// While about:addons tab is already open, new addon cards for newly installed
// addons are created from the `onInstalled` AOM events, for the `SitePermsAddonWrapper`
// the `onInstalling` and `onInstalled` events are emitted by `SitePermsAddonInstall`
// and the addon instance is going to be a `SitePermsAddonInstalling` instance if
// there wasn't an AddonCard for the same addon id yet.
//
// To make sure that all permissions will be uninstalled if a user uninstall the
// addon from an AddonCard created from a `SitePermsAddonInstalling` instance,
// we forward calls to the uninstall method of the existing `SitePermsAddonWrapper`
// instance being tracked by the `SitePermsAddonProvider`.
// If there isn't any then removing only the single permission added along with the
// `SitePremsAddonInstalling` is going to be enough.
if (this.existingAddon) {
return this.existingAddon.uninstall();
}
return super.uninstall();
}
validInstallOrigins() {
// Always return true from here,
// actual checks are done from AddonManagerInternal.getSitePermsAddonInstallForWebpage
return true;
}
}
// Numeric id included in the install telemetry events to correlate multiple events related
// to the same install or update flow.
let nextInstallId = 0;
class SitePermsAddonInstall {
#listeners = new Set();
#installEvents = {
INSTALL_CANCELLED: "onInstallCancelled",
INSTALL_ENDED: "onInstallEnded",
INSTALL_FAILED: "onInstallFailed",
};
/**
* @param {nsIPrincipal} installingPrincipal
* @param {String} sitePerm
*/
constructor(installingPrincipal, sitePerm) {
this.principal = installingPrincipal;
this.newSitePerm = sitePerm;
this.state = lazy.AddonManager.STATE_DOWNLOADED;
this.addon = new SitePermsAddonInstalling(
this.principal.siteOriginNoSuffix,
this
);
this.installId = ++nextInstallId;
}
get installTelemetryInfo() {
return this.addon.installTelemetryInfo;
}
async checkPrompt() {
// `promptHandler` can be set from `AddonManagerInternal.setupPromptHandler`
if (this.promptHandler) {
let info = {
// TODO: Investigate if we need to handle addon "update", i.e. granting new
// gated permission on an origin other permissions were already granted for (Bug 1790778).
existingAddon: null,
addon: this.addon,
// Used in AMTelemetry to detect the install flow related to this prompt.
install: this,
};
try {
await this.promptHandler(info);
} catch (err) {
if (this.error < 0) {
this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
// In some cases onOperationCancelled is called during failures
// to install/uninstall/enable/disable addons. We may need to
// do that here in the future.
this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
} else if (this.state !== lazy.AddonManager.STATE_CANCELLED) {
this.cancel();
}
return;
}
}
this.state = lazy.AddonManager.STATE_PROMPTS_DONE;
this.install();
}
install() {
if (this.state === lazy.AddonManager.STATE_PROMPTS_DONE) {
lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this.addon);
Services.perms.addFromPrincipal(
this.principal,
this.newSitePerm,
Services.perms.ALLOW_ACTION
);
this.state = lazy.AddonManager.STATE_INSTALLED;
this.#callInstallListeners(this.#installEvents.INSTALL_ENDED);
lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
this.addon.install = null;
return;
}
if (this.state !== lazy.AddonManager.STATE_DOWNLOADED) {
this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
return;
}
this.checkPrompt();
}
cancel() {
// This method can be called if the install is already cancelled.
// We don't want to go further in such case as it would lead to duplicated Telemetry events.
if (this.state == lazy.AddonManager.STATE_CANCELLED) {
console.error("SitePermsAddonInstall#cancel called twice on ", this);
return;
}
this.state = lazy.AddonManager.STATE_CANCELLED;
this.#callInstallListeners(this.#installEvents.INSTALL_CANCELLED);
}
/**
* Add a listener for the install events
*
* @param {Object} listener
* @param {Function} [listener.onDownloadEnded]
* @param {Function} [listener.onInstallCancelled]
* @param {Function} [listener.onInstallEnded]
* @param {Function} [listener.onInstallFailed]
*/
addListener(listener) {
this.#listeners.add(listener);
}
/**
* Remove a listener
*
* @param {Object} listener: The same object reference that was used for `addListener`
*/
removeListener(listener) {
this.#listeners.delete(listener);
}
/**
* Call the listeners callbacks for a given event.
*
* @param {String} eventName: The event to fire. Should be one of `this.#installEvents`
*/
#callInstallListeners(eventName) {
if (!Object.values(this.#installEvents).includes(eventName)) {
console.warn(`Unknown "${eventName}" "event`);
return;
}
lazy.AddonManagerPrivate.callInstallListeners(
eventName,
Array.from(this.#listeners),
this
);
}
}
const SitePermsAddonProvider = {
get name() {
return "SitePermsAddonProvider";
},
wrappersMapByOrigin: new Map(),
/**
* Update wrappersMapByOrigin on perm-changed
*
* @param {nsIPermission} permission: The permission being added/removed
* @param {String} action: The action perm-changed notifies us about
*/
handlePermissionChange(permission, action = "added") {
// Bail out if it it's not a gated perm
if (!isGatedPermissionType(permission.type)) {
return;
}
// Gated APIs should probably not be available on non-secure origins,
// but let's double check here.
if (permission.principal.scheme !== "https") {
return;
}
if (isPrincipalInSitePermissionsBlocklist(permission.principal)) {
return;
}
const { siteOriginNoSuffix } = permission.principal;
// Install origin cannot be on a known etld (e.g. github.io).
// We shouldn't get a permission change for those here, but let's
// be extra safe
if (isKnownPublicSuffix(siteOriginNoSuffix)) {
return;
}
// Pipe the change to the existing addon if there is one.
if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
this.wrappersMapByOrigin
.get(siteOriginNoSuffix)
.handlePermissionChange(permission, action);
}
if (action == "added") {
// We only have one SitePermsAddon per origin, handling multiple permissions.
if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
return;
}
const addonWrapper = new SitePermsAddonWrapper(siteOriginNoSuffix, [
permission,
]);
this.wrappersMapByOrigin.set(siteOriginNoSuffix, addonWrapper);
return;
}
if (action == "deleted") {
if (!this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
return;
}
// Only remove the addon if it doesn't have any permissions left.
if (!this.wrappersMapByOrigin.get(siteOriginNoSuffix).isUninstalled) {
return;
}
this.wrappersMapByOrigin.delete(siteOriginNoSuffix);
}
},
/**
* Returns a Promise that resolves when handled the list of gated permissions
* and setup ther observer for the "perm-changed" event.
*
* @returns Promise
*/
lazyInit() {
if (!this._initPromise) {
this._initPromise = new Promise(resolve => {
// Build the initial list of addons per origin
const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS);
for (const perm of perms) {
this.handlePermissionChange(perm);
}
Services.obs.addObserver(this, "perm-changed");
resolve();
});
}
return this._initPromise;
},
shutdown() {
if (this._initPromise) {
Services.obs.removeObserver(this, "perm-changed");
}
this.wrappersMapByOrigin.clear();
this._initPromise = null;
},
/**
* Get a SitePermsAddonWrapper from an extension id
*
* @param {String|null|undefined} id: The extension id,
* @returns {SitePermsAddonWrapper|undefined}
*/
async getAddonByID(id) {
await this.lazyInit();
if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) {
return undefined;
}
for (const addon of this.wrappersMapByOrigin.values()) {
if (addon.id === id) {
return addon;
}
}
return undefined;
},
/**
* Get a list of SitePermsAddonWrapper for a given list of extension types.
*
* @param {Array<String>|null|undefined} types: If null or undefined is passed,
* the callsites expect to get all the addons from the provider, without
* any filtering.
* @returns {Array<SitePermsAddonWrapper>}
*/
async getAddonsByTypes(types) {
if (
!this.isEnabled ||
// `types` can be null/undefined, and in such case we _do_ want to return the addons.
(Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE))
) {
return [];
}
await this.lazyInit();
return Array.from(this.wrappersMapByOrigin.values());
},
/**
* Create and return a SitePermsAddonInstall instance for a permission on a given principal
*
* @param {nsIPrincipal} installingPrincipal
* @param {String} sitePerm
* @returns {SitePermsAddonInstall}
*/
getSitePermsAddonInstallForWebpage(installingPrincipal, sitePerm) {
return new SitePermsAddonInstall(installingPrincipal, sitePerm);
},
get isEnabled() {
return lazy.SITEPERMS_ADDON_PROVIDER_ENABLED;
},
observe(subject, topic, data) {
if (!this.isEnabled) {
return;
}
if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [
SITEPERMS_ADDON_TYPE,
]);
Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered");
} else if (topic === "perm-changed") {
if (data === "cleared") {
// In such case, `subject` is null, but we can simply uninstall all existing addons.
for (const addon of this.wrappersMapByOrigin.values()) {
addon.uninstall();
}
this.wrappersMapByOrigin.clear();
return;
}
const perm = subject.QueryInterface(Ci.nsIPermission);
this.handlePermissionChange(perm, data);
}
},
addFirstContentProcessObserver() {
Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
},
};
// We want to register the SitePermsAddonProvider once the first content process gets created
// (and only if the feature is also enabled through the "dom.sitepermsaddon-provider.enabled"
// about:config pref).
SitePermsAddonProvider.addFirstContentProcessObserver();