Bug 1197420 Part 4 Apply dynamic permission changes r=kmag draft
authorAndrew Swan <aswan@mozilla.com>
Thu, 23 Mar 2017 17:28:52 -0700
changeset 538632 40f53e5f34f02749a5027aa324cf0843c5d2c837
parent 538631 d5cc18abbae6809b196f8497ff91608d662d5030
child 538633 d8432452d69eb7eda7057ae937f3c7ac5c514ac8
child 551302 6a22477086e08520b43cc5daf26f1dd74c1a67e5
child 551366 2d897f5c1353e69b1aab17713627960806c159fe
push id50951
push useraswan@mozilla.com
push dateFri, 24 Mar 2017 21:33:07 +0000
reviewerskmag
bugs1197420
milestone55.0a1
Bug 1197420 Part 4 Apply dynamic permission changes r=kmag MozReview-Commit-ID: 6TdcUv1fHPh
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionPermissions.jsm
toolkit/components/extensions/schemas/manifest.json
toolkit/modules/addons/MatchPattern.jsm
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -31,16 +31,18 @@ Cu.import("resource://gre/modules/Servic
 XPCOMUtils.defineLazyPreferenceGetter(this, "processCount", "dom.ipc.processCount.extension");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionAPIs",
                                   "resource://gre/modules/ExtensionAPI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPermissions",
+                                  "resource://gre/modules/ExtensionPermissions.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
                                   "resource://gre/modules/ExtensionStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestCommon",
                                   "resource://testing-common/ExtensionTestCommon.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Log",
                                   "resource://gre/modules/Log.jsm");
@@ -627,17 +629,17 @@ this.ExtensionData = class {
       this.localeData.selectedLocale = locale;
       return results[0];
     }.bind(this));
   }
 };
 
 let _browserUpdated = false;
 
-const PROXIED_EVENTS = new Set(["test-harness-message"]);
+const PROXIED_EVENTS = new Set(["test-harness-message", "add-permissions", "remove-permissions"]);
 
 // We create one instance of this class per extension. |addonData|
 // comes directly from bootstrap.js when initializing.
 this.Extension = class extends ExtensionData {
   constructor(addonData, startupReason) {
     super(addonData.resourceURI);
 
     this.uuid = UUIDMap.get(addonData.id);
@@ -679,16 +681,38 @@ this.Extension = class extends Extension
     this.uninstallURL = null;
 
     this.apis = [];
     this.whiteListedHosts = null;
     this._optionalOrigins = null;
     this.webAccessibleResources = null;
 
     this.emitter = new EventEmitter();
+
+    /* eslint-disable mozilla/balanced-listeners */
+    this.on("add-permissions", (ignoreEvent, permissions) => {
+      for (let perm of permissions.permissions) {
+        this.permissions.add(perm);
+      }
+
+      if (permissions.origins.length > 0) {
+        this.whiteListedHosts = new MatchPattern(this.whiteListedHosts.pat.concat(...permissions.origins));
+      }
+    });
+
+    this.on("remove-permissions", (ignoreEvent, permissions) => {
+      for (let perm of permissions.permissions) {
+        this.permissions.delete(perm);
+      }
+
+      for (let origin of permissions.origins) {
+        this.whiteListedHosts.removeOne(origin);
+      }
+    });
+    /* eslint-enable mozilla/balanced-listeners */
   }
 
   static set browserUpdated(updated) {
     _browserUpdated = updated;
   }
 
   static get browserUpdated() {
     return _browserUpdated;
@@ -792,16 +816,17 @@ this.Extension = class extends Extension
       resourceURL: this.addonData.resourceURI.spec,
       baseURL: this.baseURI.spec,
       content_scripts: this.manifest.content_scripts || [],  // eslint-disable-line camelcase
       webAccessibleResources: this.webAccessibleResources.serialize(),
       whiteListedHosts: this.whiteListedHosts.serialize(),
       localeData: this.localeData.serialize(),
       permissions: this.permissions,
       principal: this.principal,
+      optionalPermissions: this.manifest.optional_permissions,
     };
   }
 
   broadcast(msg, data) {
     return new Promise(resolve => {
       let {ppmm} = Services;
       let children = new Set();
       for (let i = 0; i < ppmm.childCount; i++) {
@@ -889,58 +914,69 @@ this.Extension = class extends Extension
 
       let match = Locale.findClosestLocale(localeList);
       locale = match ? match.name : this.defaultLocale;
     }
 
     return super.initLocale(locale);
   }
 
-  startup() {
+  async startup() {
     let started = false;
-    return this.loadManifest().then(() => {
+
+    try {
+      let [, perms] = await Promise.all([this.loadManifest(), ExtensionPermissions.get(this)]);
+
       ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this);
       started = true;
 
       if (!this.hasShutdown) {
-        return this.initLocale();
+        await this.initLocale();
       }
-    }).then(() => {
+
       if (this.errors.length) {
         return Promise.reject({errors: this.errors});
       }
 
       if (this.hasShutdown) {
         return;
       }
 
       GlobalManager.init(this);
 
+      // Apply optional permissions
+      for (let perm of perms.permissions) {
+        this.permissions.add(perm);
+      }
+      if (perms.origins.length > 0) {
+        this.whiteListedHosts = new MatchPattern(this.whiteListedHosts.pat.concat(...perms.origins));
+      }
+
       // The "startup" Management event sent on the extension instance itself
       // is emitted just before the Management "startup" event,
       // and it is used to run code that needs to be executed before
       // any of the "startup" listeners.
       this.emit("startup", this);
       Management.emit("startup", this);
 
-      return this.runManifest(this.manifest);
-    }).then(() => {
+      await this.runManifest(this.manifest);
+
       Management.emit("ready", this);
-    }).catch(e => {
+    } catch (e) {
       dump(`Extension error: ${e.message} ${e.filename || e.fileName}:${e.lineNumber} :: ${e.stack || new Error().stack}\n`);
       Cu.reportError(e);
 
       if (started) {
         ExtensionManagement.shutdownExtension(this.uuid);
       }
 
       this.cleanupGeneratedFile();
 
       throw e;
-    });
+    }
   }
 
   cleanupGeneratedFile() {
     if (!this.cleanupFile) {
       return;
     }
 
     let file = this.cleanupFile;
@@ -1002,23 +1038,31 @@ this.Extension = class extends Extension
   }
 
   observe(subject, topic, data) {
     if (topic === "xpcom-shutdown") {
       this.cleanupGeneratedFile();
     }
   }
 
-  hasPermission(perm) {
+  hasPermission(perm, includeOptional = false) {
     let match = /^manifest:(.*)/.exec(perm);
     if (match) {
       return this.manifest[match[1]] != null;
     }
 
-    return this.permissions.has(perm);
+    if (this.permissions.has(perm)) {
+      return true;
+    }
+
+    if (includeOptional && this.manifest.optional_permissions.includes(perm)) {
+      return true;
+    }
+
+    return false;
   }
 
   get name() {
     return this.manifest.name;
   }
 
   get optionalOrigins() {
     if (this._optionalOrigins == null) {
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -622,16 +622,32 @@ class ChildAPIManager {
     let params = {
       childId: this.id,
       extensionId: context.extension.id,
       principal: context.principal,
     };
     Object.assign(params, contextData);
 
     this.messageManager.sendAsyncMessage("API:CreateProxyContext", params);
+
+    this.permissionsChangedCallbacks = new Set();
+    this.updatePermissions = null;
+    if (this.context.extension.optionalPermissions.length > 0) {
+      this.updatePermissions = () => {
+        for (let callback of this.permissionsChangedCallbacks) {
+          try {
+            callback();
+          } catch (err) {
+            Cu.reportError(err);
+          }
+        }
+      };
+      this.context.extension.on("add-permissions", this.updatePermissions);
+      this.context.extension.on("remove-permissions", this.updatePermissions);
+    }
   }
 
   receiveMessage({name, messageName, data}) {
     if (data.childId != this.id) {
       return;
     }
 
     switch (name || messageName) {
@@ -721,16 +737,20 @@ class ChildAPIManager {
       addListener: (listener, ...args) => impl.addListener(listener, args),
       removeListener: (listener) => impl.removeListener(listener),
       hasListener: (listener) => impl.hasListener(listener),
     };
   }
 
   close() {
     this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id});
+    if (this.updatePermissions) {
+      this.context.extension.off("add-permissions", this.updatePermissions);
+      this.context.extension.off("remove-permissions", this.updatePermissions);
+    }
   }
 
   get cloneScope() {
     return this.context.cloneScope;
   }
 
   get principal() {
     return this.context.principal;
@@ -776,16 +796,24 @@ class ChildAPIManager {
   getFallbackImplementation(namespace, name) {
     // No local API found, defer implementation to the parent.
     return new ProxyAPIImplementation(namespace, name, this);
   }
 
   hasPermission(permission) {
     return this.context.extension.hasPermission(permission);
   }
+
+  isPermissionRevokable(permission) {
+    return this.context.extension.optionalPermissions.includes(permission);
+  }
+
+  setPermissionsChangedCallback(callback) {
+    this.permissionsChangedCallbacks.add(callback);
+  }
 }
 
 class ExtensionBaseContextChild extends BaseContext {
   /**
    * This ExtensionBaseContextChild represents an addon execution environment
    * that is running in an addon or devtools child process.
    *
    * @param {BrowserExtensionContent} extension This context's owner.
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -684,18 +684,21 @@ class SchemaAPIManager extends EventEmit
           }
           copy(dest[prop], source[prop]);
         } else {
           Object.defineProperty(dest, prop, desc);
         }
       }
     }
 
+    function hasPermission(perm) {
+      return context.extension.hasPermission(perm, true);
+    }
     for (let api of apis) {
-      if (Schemas.checkPermissions(api.namespace, context.extension)) {
+      if (Schemas.checkPermissions(api.namespace, {hasPermission})) {
         api = api.getAPI(context);
         copy(obj, api);
       }
     }
   }
 }
 
 const ExtensionCommon = {
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -986,16 +986,17 @@ class BrowserExtensionContent extends Ev
 
     defineLazyGetter(this, "scripts", () => {
       return data.content_scripts.map(scriptData => new Script(this, scriptData));
     });
 
     this.webAccessibleResources = new MatchGlobs(data.webAccessibleResources);
     this.whiteListedHosts = new MatchPattern(data.whiteListedHosts);
     this.permissions = data.permissions;
+    this.optionalPermissions = data.optionalPermissions;
     this.principal = data.principal;
 
     this.localeData = new LocaleData(data.localeData);
 
     this.manifest = data.manifest;
     this.baseURI = Services.io.newURI(data.baseURL);
 
     // Only used in addon processes.
@@ -1005,16 +1006,44 @@ class BrowserExtensionContent extends Ev
     this.devtoolsViews = new Set();
 
     let uri = Services.io.newURI(data.resourceURL);
 
     if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
       // Extension.jsm takes care of this in the parent.
       ExtensionManagement.startupExtension(this.uuid, uri, this);
     }
+
+    /* eslint-disable mozilla/balanced-listeners */
+    this.on("add-permissions", (ignoreEvent, permissions) => {
+      if (permissions.permissions.length > 0) {
+        for (let perm of permissions.permissions) {
+          this.permissions.add(perm);
+        }
+      }
+
+      if (permissions.origins.length > 0) {
+        this.whiteListedHosts = new MatchPattern(this.whiteListedHosts.pat.concat(...permissions.origins));
+      }
+    });
+
+    this.on("remove-permissions", (ignoreEvent, permissions) => {
+      if (permissions.permissions.length > 0) {
+        for (let perm of permissions.permissions) {
+          this.permissions.delete(perm);
+        }
+      }
+
+      if (permissions.origins.length > 0) {
+        for (let origin of permissions.origins) {
+          this.whiteListedHosts.removeOne(origin);
+        }
+      }
+    });
+    /* eslint-enable mozilla/balanced-listeners */
   }
 
   shutdown() {
     Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this);
 
     if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
       ExtensionManagement.shutdownExtension(this.uuid);
     }
--- a/toolkit/components/extensions/ExtensionPermissions.jsm
+++ b/toolkit/components/extensions/ExtensionPermissions.jsm
@@ -61,17 +61,17 @@ this.ExtensionPermissions = {
       if (!origins.includes(origin)) {
         added.origins.push(origin);
         origins.push(origin);
       }
     }
 
     if (added.permissions.length > 0 || added.origins.length > 0) {
       prefs.saveSoon();
-      // TODO apply the changes
+      extension.emit("add-permissions", added);
     }
   },
 
   // Revoke permissions from the given extension.  `permissions` is
   // in the format that is passed to browser.permissions.remove().
   async remove(extension, perms) {
     await lazyInit();
 
@@ -94,17 +94,17 @@ this.ExtensionPermissions = {
       if (i >= 0) {
         removed.origins.push(origin);
         origins.splice(i, 1);
       }
     }
 
     if (removed.permissions.length > 0 || removed.origins.length > 0) {
       prefs.saveSoon();
-      // TODO apply the changes
+      extension.emit("remove-permissions", removed);
     }
   },
 
   async removeAll(extension) {
     await lazyInit();
     delete prefs.data[extension.id];
     prefs.saveSoon();
   },
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -177,17 +177,18 @@
               ]
             },
             "optional": true
           },
 
           "optional_permissions": {
             "type": "array",
             "items": { "$ref": "OptionalPermission" },
-            "optional": true
+            "optional": true,
+            "default": []
           },
 
           "web_accessible_resources": {
             "type": "array",
             "items": { "type": "string" },
             "optional": true
           },
 
--- a/toolkit/modules/addons/MatchPattern.jsm
+++ b/toolkit/modules/addons/MatchPattern.jsm
@@ -194,16 +194,31 @@ MatchPattern.prototype = {
     }
 
     return this.matchesIgnoringPath({scheme: match[1], host: match[2]});
   },
 
   serialize() {
     return this.pat;
   },
+
+  removeOne(pattern) {
+    if (!Array.isArray(this.pat)) {
+      return;
+    }
+
+    let index = this.pat.indexOf(pattern);
+    if (index >= 0) {
+      if (this.matchers[index].pat != pattern) {
+        throw new Error("pat/matcher mismatch in removeOne()");
+      }
+      this.pat.splice(index, 1);
+      this.matchers.splice(index, 1);
+    }
+  },
 };
 
 // Globs can match everything. Be careful, this DOES NOT filter by allowed schemes!
 this.MatchGlobs = function(globs) {
   this.original = globs;
   if (globs) {
     this.regexps = Array.from(globs, (glob) => globToRegexp(glob, true));
   } else {