Bug 1522823 - Policy for whitelist/blacklist addons by ID. r=aswan,flod
authorMichael Kaply <mozilla@kaply.com>
Wed, 15 May 2019 01:22:39 +0000
changeset 473880 61f3f19ee0de3b05060506337f8fb71a08c2e07b
parent 473879 28b9af9efee6a0317e21fa3a1971f039f53a25ed
child 473881 91786919a41c8555276d0805c5bf5d98e82c40dd
push id36017
push userrgurzau@mozilla.com
push dateWed, 15 May 2019 09:25:56 +0000
treeherdermozilla-central@76bbedc1ec1a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, flod
bugs1522823
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1522823 - Policy for whitelist/blacklist addons by ID. r=aswan,flod Differential Revision: https://phabricator.services.mozilla.com/D27902
browser/base/content/browser-addons.js
browser/components/enterprisepolicies/Policies.jsm
browser/components/enterprisepolicies/schemas/policies-schema.json
browser/components/enterprisepolicies/tests/browser/browser.ini
browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
browser/components/enterprisepolicies/tests/browser/extensionsettings.html
browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js
browser/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
browser/locales/en-US/browser/policies/policies-descriptions.ftl
browser/locales/en-US/chrome/browser/browser.properties
toolkit/components/enterprisepolicies/EnterprisePolicies.js
toolkit/components/enterprisepolicies/nsIEnterprisePolicies.idl
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/XPIDatabase.jsm
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -575,16 +575,26 @@ var gXPInstallObserver = {
         } else if (install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) {
           error += "Blocklisted";
           args = [install.name];
         } else {
           error += "Incompatible";
           args = [brandShortName, Services.appinfo.version, install.name];
         }
 
+        if (install.addon && !Services.policies.mayInstallAddon(install.addon)) {
+          error = "addonInstallBlockedByPolicy";
+          let extensionSettings = Services.policies.getExtensionSettings(install.addon.id);
+          let message = "";
+          if (extensionSettings && "blocked_install_message" in extensionSettings) {
+            message = " " + extensionSettings.blocked_install_message;
+          }
+          args = [install.name, install.addon.id, message];
+        }
+
         // Add Learn More link when refusing to install an unsigned add-on
         if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) {
           options.learnMoreURL = Services.urlFormatter.formatURLPref("app.support.baseURL") + "unsigned-addons";
         }
 
         messageString = gNavigatorBundle.getFormattedString(error, args);
 
         PopupNotifications.show(browser, notificationID, messageString, anchorID,
--- a/browser/components/enterprisepolicies/Policies.jsm
+++ b/browser/components/enterprisepolicies/Policies.jsm
@@ -537,89 +537,129 @@ var Policies = {
           Services.prefs.clearUserPref("browser.policies.runOncePerModification.extensionsInstall");
           let addons = await AddonManager.getAddonsByIDs(param.Uninstall);
           for (let addon of addons) {
             if (addon) {
               try {
                 await addon.uninstall();
               } catch (e) {
                 // This can fail for add-ons that can't be uninstalled.
-                // Just ignore.
+                log.debug(`Add-on ID (${addon.id}) couldn't be uninstalled.`);
               }
             }
           }
         });
       }
       if ("Install" in param) {
         runOncePerModification("extensionsInstall", JSON.stringify(param.Install), async () => {
           await uninstallingPromise;
           for (let location of param.Install) {
-            let url;
-            if (location.includes("://")) {
-              // Assume location is an URI
-              url = location;
-            } else {
+            let uri;
+            try {
+              uri = Services.io.newURI(location);
+            } catch (e) {
+              // If it's not a URL, it's probably a file path.
               // Assume location is a file path
-              let xpiFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+              // This is done for legacy support (old API)
               try {
-                xpiFile.initWithPath(location);
-              } catch (e) {
+                let xpiFile = new FileUtils.File(location);
+                uri = Services.io.newFileURI(xpiFile);
+              } catch (ex) {
                 log.error(`Invalid extension path location - ${location}`);
-                continue;
-              }
-              url = Services.io.newFileURI(xpiFile).spec;
-            }
-            AddonManager.getInstallForURL(url, {
-              telemetryInfo: {source: "enterprise-policy"},
-            }).then(install => {
-              if (install.addon && install.addon.appDisabled) {
-                log.error(`Incompatible add-on - ${location}`);
-                install.cancel();
                 return;
               }
-              let listener = {
-              /* eslint-disable-next-line no-shadow */
-                onDownloadEnded: (install) => {
-                  if (install.addon && install.addon.appDisabled) {
-                    log.error(`Incompatible add-on - ${location}`);
-                    install.removeListener(listener);
-                    install.cancel();
-                  }
-                },
-                onDownloadFailed: () => {
-                  install.removeListener(listener);
-                  log.error(`Download failed - ${location}`);
-                  clearRunOnceModification("extensionsInstall");
-                },
-                onInstallFailed: () => {
-                  install.removeListener(listener);
-                  log.error(`Installation failed - ${location}`);
-                },
-                onInstallEnded: () => {
-                  install.removeListener(listener);
-                  log.debug(`Installation succeeded - ${location}`);
-                },
-              };
-              install.addListener(listener);
-              install.install();
-            });
+            }
+            installAddonFromURL(uri.spec);
           }
         });
       }
       if ("Locked" in param) {
         for (let ID of param.Locked) {
-          manager.disallowFeature(`modify-extension:${ID}`);
+          manager.disallowFeature(`uninstall-extension:${ID}`);
+          manager.disallowFeature(`disable-extension:${ID}`);
         }
       }
     },
   },
 
   "ExtensionSettings": {
     onBeforeAddons(manager, param) {
-      manager.setExtensionSettings(param);
+      try {
+        manager.setExtensionSettings(param);
+      } catch (e) {
+       log.error("Invalid ExtensionSettings");
+      }
+    },
+    async onBeforeUIStartup(manager, param) {
+      let extensionSettings = param;
+      let blockAllExtensions = false;
+      if ("*" in extensionSettings) {
+        if ("installation_mode" in extensionSettings["*"] &&
+            extensionSettings["*"].installation_mode == "blocked") {
+          blockAllExtensions = true;
+          // Turn off discovery pane in about:addons
+          setAndLockPref("extensions.getAddons.showPane", false);
+          // Block about:debugging
+          blockAboutPage(manager, "about:debugging");
+        }
+      }
+      let {addons} = await AddonManager.getActiveAddons();
+      let allowedExtensions = [];
+      for (let extensionID in extensionSettings) {
+        if (extensionID == "*") {
+          // Ignore global settings
+          continue;
+        }
+        if ("installation_mode" in extensionSettings[extensionID]) {
+          if (extensionSettings[extensionID].installation_mode == "force_installed" ||
+              extensionSettings[extensionID].installation_mode == "normal_installed") {
+            if (!extensionSettings[extensionID].install_url) {
+              throw new Error(`Missing install_url for ${extensionID}`);
+            }
+            if (!addons.find(addon => addon.id == extensionID)) {
+              installAddonFromURL(extensionSettings[extensionID].install_url, extensionID);
+            }
+            manager.disallowFeature(`uninstall-extension:${extensionID}`);
+            if (extensionSettings[extensionID].installation_mode == "force_installed") {
+              manager.disallowFeature(`disable-extension:${extensionID}`);
+            }
+            allowedExtensions.push(extensionID);
+          } else if (extensionSettings[extensionID].installation_mode == "allowed") {
+            allowedExtensions.push(extensionID);
+          } else if (extensionSettings[extensionID].installation_mode == "blocked") {
+            if (addons.find(addon => addon.id == extensionID)) {
+              // Can't use the addon from getActiveAddons since it doesn't have uninstall.
+              let addon = await AddonManager.getAddonByID(extensionID);
+              try {
+                await addon.uninstall();
+              } catch (e) {
+                // This can fail for add-ons that can't be uninstalled.
+                log.debug(`Add-on ID (${addon.id}) couldn't be uninstalled.`);
+              }
+            }
+          }
+        }
+      }
+      if (blockAllExtensions) {
+        for (let addon of addons) {
+          if (addon.isSystem || addon.isBuiltin) {
+            continue;
+          }
+          if (!allowedExtensions.includes(addon.id)) {
+            try {
+              // Can't use the addon from getActiveAddons since it doesn't have uninstall.
+              let addonToUninstall = await AddonManager.getAddonByID(addon.id);
+              await addonToUninstall.uninstall();
+            } catch (e) {
+              // This can fail for add-ons that can't be uninstalled.
+              log.debug(`Add-on ID (${addon.id}) couldn't be uninstalled.`);
+            }
+          }
+        }
+      }
     },
   },
 
   "ExtensionUpdate": {
     onBeforeAddons(manager, param) {
       if (!param) {
         setAndLockPref("extensions.update.enabled", param);
       }
@@ -1299,16 +1339,64 @@ function clearRunOnceModification(action
 
 function replacePathVariables(path) {
   if (path.includes("${home}")) {
     return path.replace("${home}", FileUtils.getFile("Home", []).path);
   }
   return path;
 }
 
+/**
+ * installAddonFromURL
+ *
+ * Helper function that installs an addon from a URL
+ * and verifies that the addon ID matches.
+*/
+function installAddonFromURL(url, extensionID) {
+  AddonManager.getInstallForURL(url, {
+    telemetryInfo: {source: "enterprise-policy"},
+  }).then(install => {
+    if (install.addon && install.addon.appDisabled) {
+      log.error(`Incompatible add-on - ${location}`);
+      install.cancel();
+      return;
+    }
+    let listener = {
+    /* eslint-disable-next-line no-shadow */
+      onDownloadEnded: (install) => {
+        if (extensionID && install.addon.id != extensionID) {
+          log.error(`Add-on downloaded from ${url} had unexpected id (got ${install.addon.id} expected ${extensionID})`);
+          install.removeListener(listener);
+          install.cancel();
+        }
+        if (install.addon && install.addon.appDisabled) {
+          log.error(`Incompatible add-on - ${url}`);
+          install.removeListener(listener);
+          install.cancel();
+        }
+      },
+      onDownloadFailed: () => {
+        install.removeListener(listener);
+        log.error(`Download failed - ${url}`);
+        clearRunOnceModification("extensionsInstall");
+      },
+      onInstallFailed: () => {
+        install.removeListener(listener);
+        log.error(`Installation failed - ${url}`);
+      },
+      onInstallEnded: () => {
+        install.removeListener(listener);
+        log.debug(`Installation succeeded - ${url}`);
+      },
+    };
+    install.addListener(listener);
+    install.install();
+  });
+}
+
 let gChromeURLSBlocked = false;
 
 // If any about page is blocked, we block the loading of all
 // chrome:// URLs in the browser window.
 function blockAboutPage(manager, feature, neededOnContentProcess = false) {
   manager.disallowFeature(feature, neededOnContentProcess);
   if (!gChromeURLSBlocked) {
     blockAllChromeURLs();
--- a/browser/components/enterprisepolicies/schemas/policies-schema.json
+++ b/browser/components/enterprisepolicies/schemas/policies-schema.json
@@ -318,20 +318,54 @@
             "type": "string"
           }
         }
       }
     },
 
     "ExtensionSettings": {
       "type": "object",
+      "properties": {
+        "*": {
+          "type": "object",
+          "properties": {
+            "installation_mode": {
+              "type": "string",
+              "enum": ["allowed", "blocked"]
+            },
+            "allowed_types": {
+              "type": "array",
+              "items": {
+                "type": "string",
+                "enum": ["extension", "dictionary", "locale", "theme"]
+              }
+            },
+            "blocked_install_message": {
+              "type": "string"
+            },
+            "install_sources": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      },
       "patternProperties": {
         "^.*$": {
           "type": "object",
           "properties": {
+            "installation_mode": {
+              "type": "string",
+              "enum": ["allowed", "blocked", "force_installed", "normal_installed"]
+            },
+            "install_url": {
+              "type": "string"
+            },
             "blocked_install_message": {
               "type": "string"
             }
           }
         }
       }
     },
 
--- a/browser/components/enterprisepolicies/tests/browser/browser.ini
+++ b/browser/components/enterprisepolicies/tests/browser/browser.ini
@@ -4,16 +4,17 @@ support-files =
   opensearch.html
   opensearchEngine.xml
   policytest_v0.1.xpi
   policytest_v0.2.xpi
   policy_websitefilter_block.html
   policy_websitefilter_exception.html
   ../../../../../toolkit/components/antitracking/test/browser/page.html
   ../../../../../toolkit/components/antitracking/test/browser/subResources.sjs
+  extensionsettings.html
 
 [browser_policies_getActivePolicies.js]
 skip-if = os != 'mac'
 [browser_policies_notice_in_aboutpreferences.js]
 [browser_policies_setAndLockPref_API.js]
 [browser_policy_app_update.js]
 [browser_policy_block_about_addons.js]
 [browser_policy_block_about_config.js]
--- a/browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
+++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_extensionsettings.js
@@ -1,23 +1,207 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
-add_task(async function test_extensionsettings() {
+const BASE_URL = "http://mochi.test:8888/browser/browser/components/enterprisepolicies/tests/browser/";
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ *        The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ *          Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+  return new Promise(resolve => {
+    function popupshown() {
+      let notification = PopupNotifications.getNotification(name);
+      if (!notification) { return; }
+
+      ok(notification, `${name} notification shown`);
+      ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+      PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+      resolve(PopupNotifications.panel.firstElementChild);
+    }
+
+    PopupNotifications.panel.addEventListener("popupshown", popupshown);
+  });
+}
+
+add_task(async function test_install_source_blocked_link() {
   await setupPolicyEngineWithJson({
     "policies": {
       "ExtensionSettings": {
-        "extension1@mozilla.com": {
-          "blocked_install_message": "Extension1 error message.",
+        "*": {
+          "install_sources": ["http://blocks.other.install.sources/*"],
         },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-install-origin-blocked");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {}, () => {
+    content.document.getElementById("policytest").click();
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_installtrigger() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
         "*": {
-          "blocked_install_message": "Generic error message.",
+          "install_sources": ["http://blocks.other.install.sources/*"],
+        },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-install-origin-blocked");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {}, () => {
+    content.document.getElementById("policytest_installtrigger").click();
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_otherdomain() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "*": {
+          "install_sources": ["http://mochi.test/*"],
         },
       },
     },
   });
+  let popupPromise = promisePopupNotificationShown("addon-install-origin-blocked");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
 
-  let extensionSettings =  Services.policies.getExtensionSettings("extension1@mozilla.com");
-  is(extensionSettings.blocked_install_message, "Extension1 error message.", "Should have extension specific message.");
-  extensionSettings =  Services.policies.getExtensionSettings("extension2@mozilla.com");
-  is(extensionSettings.blocked_install_message, "Generic error message.", "Should have generic message.");
+  await ContentTask.spawn(tab.linkedBrowser, {}, () => {
+    content.document.getElementById("policytest_otherdomain").click();
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_install_source_blocked_direct() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "*": {
+          "install_sources": ["http://blocks.other.install.sources/*"],
+        },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-install-origin-blocked");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {baseUrl: BASE_URL}, async function({baseUrl}) {
+    content.document.location.href = baseUrl + "policytest_v0.1.xpi";
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_link() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "*": {
+          "install_sources": ["http://mochi.test/*"],
+        },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {}, () => {
+    content.document.getElementById("policytest").click();
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
 });
+
+add_task(async function test_install_source_allowed_installtrigger() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "*": {
+          "install_sources": ["http://mochi.test/*"],
+        },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {}, () => {
+    content.document.getElementById("policytest_installtrigger").click();
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_otherdomain() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "*": {
+          "install_sources": ["http://mochi.test/*", "http://example.org/*"],
+        },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {}, () => {
+    content.document.getElementById("policytest_otherdomain").click();
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_install_source_allowed_direct() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "*": {
+          "install_sources": ["http://mochi.test/*"],
+        },
+      },
+    },
+  });
+  let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+  let tab = await BrowserTestUtils.openNewForegroundTab({gBrowser,
+    opening: BASE_URL + "extensionsettings.html",
+    waitForStateStop: true});
+
+  await ContentTask.spawn(tab.linkedBrowser, {baseUrl: BASE_URL}, async function({baseUrl}) {
+    content.document.location.href = baseUrl + "policytest_v0.1.xpi";
+  });
+  await popupPromise;
+  BrowserTestUtils.removeTab(tab);
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/extensionsettings.html
@@ -0,0 +1,23 @@
+
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<script type="text/javascript">
+function installTrigger(url) {
+  InstallTrigger.install({extension: url});
+}
+</script>
+</head>
+<body>
+<p>
+<a id="policytest" href="policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+<p>
+<a id="policytest_installtrigger" onclick="installTrigger(this.href);return false;" href="policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+<p>
+<a id="policytest_otherdomain" href="http://example.org:80/browser/browser/components/enterprisepolicies/tests/browser/policytest_v0.1.xpi">policytest@mozilla.com</a>
+</p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/xpcshell/test_extensionsettings.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const {AddonTestUtils} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+const {AddonManager} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "48", "48");
+
+const server = AddonTestUtils.createHttpServer({hosts: ["example.com"]});
+const BASE_URL = `http://example.com/data`;
+
+let addonID = "policytest2@mozilla.com";
+
+add_task(async function setup() {
+  await AddonTestUtils.promiseStartupManager();
+
+  let webExtensionFile = AddonTestUtils.createTempWebExtensionFile({
+    manifest: {
+      applications: {
+        gecko: {
+          id: addonID,
+        },
+      },
+    },
+  });
+
+  server.registerFile("/data/policy_test.xpi", webExtensionFile);
+});
+
+add_task(async function test_extensionsettings() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "extension1@mozilla.com": {
+          "blocked_install_message": "Extension1 error message.",
+        },
+        "*": {
+          "blocked_install_message": "Generic error message.",
+        },
+      },
+    },
+  });
+
+  let extensionSettings =  Services.policies.getExtensionSettings("extension1@mozilla.com");
+  equal(extensionSettings.blocked_install_message, "Extension1 error message.", "Should have extension specific message.");
+  extensionSettings =  Services.policies.getExtensionSettings("extension2@mozilla.com");
+  equal(extensionSettings.blocked_install_message, "Generic error message.", "Should have generic message.");
+});
+
+add_task(async function test_addon_blocked() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "policytest2@mozilla.com": {
+          "installation_mode": "blocked",
+        },
+      },
+    },
+  });
+
+  let install = await AddonManager.getInstallForURL(BASE_URL + "/policy_test.xpi");
+  await install.install();
+  notEqual(install.addon, null, "Addon should not be null");
+  equal(install.addon.appDisabled, true, "Addon should be disabled");
+  await install.addon.uninstall();
+});
+
+add_task(async function test_addon_allowed() {
+  await setupPolicyEngineWithJson({
+    "policies": {
+      "ExtensionSettings": {
+        "policytest2@mozilla.com": {
+          "installation_mode": "allowed",
+        },
+        "*": {
+          "installation_mode": "blocked",
+        },
+      },
+    },
+  });
+
+  let install = await AddonManager.getInstallForURL(BASE_URL + "/policy_test.xpi");
+  await install.install();
+  notEqual(install.addon, null, "Addon should not be null");
+  equal(install.addon.appDisabled, false, "Addon should not be disabled");
+  await install.addon.uninstall();
+});
+
+add_task(async function test_addon_uninstalled() {
+  let install = await AddonManager.getInstallForURL(BASE_URL + "/policy_test.xpi");
+  await install.install();
+  notEqual(install.addon, null, "Addon should not be null");
+
+  await Promise.all([
+    AddonTestUtils.promiseAddonEvent("onUninstalled"),
+    setupPolicyEngineWithJson({
+      "policies": {
+        "ExtensionSettings": {
+          "*": {
+            "installation_mode": "blocked",
+          },
+        },
+      },
+    }),
+  ]);
+  let addon = await AddonManager.getAddonByID(addonID);
+  equal(addon, null, "Addon should be null");
+});
+
+add_task(async function test_addon_forceinstalled() {
+  await Promise.all([
+    AddonTestUtils.promiseInstallEvent("onInstallEnded"),
+    setupPolicyEngineWithJson({
+      "policies": {
+        "ExtensionSettings": {
+          "policytest2@mozilla.com": {
+            "installation_mode": "force_installed",
+            "install_url": BASE_URL + "/policy_test.xpi",
+          },
+        },
+      },
+    }),
+  ]);
+  let addon = await AddonManager.getAddonByID(addonID);
+  notEqual(addon, null, "Addon should not be null");
+  equal(addon.appDisabled, false, "Addon should not be disabled");
+  equal(addon.permissions & AddonManager.PERM_CAN_UNINSTALL, 0, "Addon should not be able to be uninstalled.");
+  equal(addon.permissions & AddonManager.PERM_CAN_DISABLE, 0, "Addon should not be able to be disabled.");
+  await addon.uninstall();
+});
+
+add_task(async function test_addon_normalinstalled() {
+  await Promise.all([
+    AddonTestUtils.promiseInstallEvent("onInstallEnded"),
+    setupPolicyEngineWithJson({
+      "policies": {
+        "ExtensionSettings": {
+          "policytest2@mozilla.com": {
+            "installation_mode": "normal_installed",
+            "install_url": BASE_URL + "/policy_test.xpi",
+          },
+        },
+      },
+    }),
+  ]);
+  let addon = await AddonManager.getAddonByID(addonID);
+  notEqual(addon, null, "Addon should not be null");
+  equal(addon.appDisabled, false, "Addon should not be disabled");
+  equal(addon.permissions & AddonManager.PERM_CAN_UNINSTALL, 0, "Addon should not be able to be uninstalled.");
+  notEqual(addon.permissions & AddonManager.PERM_CAN_DISABLE, 0, "Addon should be able to be disabled.");
+  await addon.uninstall();
+});
--- a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
+++ b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.ini
@@ -1,16 +1,17 @@
 [DEFAULT]
 firefox-appdir = browser
 head = head.js
 
 [test_3rdparty.js]
 [test_appupdateurl.js]
 [test_clear_blocked_cookies.js]
 [test_defaultbrowsercheck.js]
+[test_extensionsettings.js]
 [test_macosparser_unflatten.js]
 skip-if = os != 'mac'
 [test_permissions.js]
 [test_popups_cookies_addons_flash.js]
 support-files = config_popups_cookies_addons_flash.json
 [test_preferences.js]
 [test_proxy.js]
 [test_requestedlocales.js]
--- a/browser/locales/en-US/browser/policies/policies-descriptions.ftl
+++ b/browser/locales/en-US/browser/policies/policies-descriptions.ftl
@@ -85,16 +85,18 @@ policy-DownloadDirectory = Set and lock 
 # “lock” means that the user won’t be able to change this setting
 policy-EnableTrackingProtection = Enable or disable Content Blocking and optionally lock it.
 
 # A “locked” extension can’t be disabled or removed by the user. This policy
 # takes 3 keys (“Install”, ”Uninstall”, ”Locked”), you can either keep them in
 # English or translate them as verbs.
 policy-Extensions = Install, uninstall or lock extensions. The Install option takes URLs or paths as parameters. The Uninstall and Locked options take extension IDs.
 
+policy-ExtensionSettings = Manage all aspects of extension installation.
+
 policy-ExtensionUpdate = Enable or disable automatic extension updates.
 
 policy-FirefoxHome = Configure Firefox Home.
 
 policy-FlashPlugin = Allow or deny usage of the Flash plugin.
 
 policy-HardwareAcceleration = If false, turn off hardware acceleration.
 
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -36,16 +36,23 @@ xpinstallPromptMessage.dontAllow.accessk
 xpinstallPromptMessage.install=Continue to Installation
 xpinstallPromptMessage.install.accesskey=C
 
 xpinstallDisabledMessageLocked=Software installation has been disabled by your system administrator.
 xpinstallDisabledMessage=Software installation is currently disabled. Click Enable and try again.
 xpinstallDisabledButton=Enable
 xpinstallDisabledButton.accesskey=n
 
+# LOCALIZATION NOTE (addonInstallBlockedByPolicy)
+# This message is shown when the installation of an add-on is blocked by
+# enterprise policy. %1$S is replaced by the name of the add-on.
+# %2$S is replaced by the ID of add-on. %3$S is a custom message that
+# the administration can add to the message.
+addonInstallBlockedByPolicy=%1$S (%2$S) is blocked by your system administrator.%3$S
+
 # LOCALIZATION NOTE (webextPerms.header)
 # This string is used as a header in the webextension permissions dialog,
 # %S is replaced with the localized name of the extension being installed.
 # See https://bug1308309.bmoattachments.org/attachment.cgi?id=8814612
 # for an example of the full dialog.
 # Note, this string will be used as raw markup. Avoid characters like <, >, &
 webextPerms.header=Add %S?
 
--- a/toolkit/components/enterprisepolicies/EnterprisePolicies.js
+++ b/toolkit/components/enterprisepolicies/EnterprisePolicies.js
@@ -295,33 +295,71 @@ EnterprisePoliciesManager.prototype = {
         extensionID in ExtensionPolicies) {
       return ExtensionPolicies[extensionID];
     }
     return null;
   },
 
   setExtensionSettings(extensionSettings) {
     ExtensionSettings = extensionSettings;
+    if ("*" in extensionSettings &&
+        "install_sources" in extensionSettings["*"]) {
+      InstallSources = new MatchPatternSet(extensionSettings["*"].install_sources);
+    }
   },
 
   getExtensionSettings(extensionID) {
     let settings = null;
-    if (extensionID in ExtensionSettings) {
-      settings = ExtensionSettings[extensionID];
-    } else if ("*" in ExtensionSettings) {
-      settings = ExtensionSettings["*"];
+    if (ExtensionSettings) {
+      if (extensionID in ExtensionSettings) {
+        settings = ExtensionSettings[extensionID];
+      } else if ("*" in ExtensionSettings) {
+        settings = ExtensionSettings["*"];
+      }
     }
     return settings;
   },
+
+  mayInstallAddon(addon) {
+    // See https://dev.chromium.org/administrators/policy-list-3/extension-settings-full
+    if (!ExtensionSettings) {
+      return true;
+    }
+    if (addon.id in ExtensionSettings) {
+      if ("installation_mode" in ExtensionSettings[addon.id]) {
+        switch (ExtensionSettings[addon.id].installation_mode) {
+          case "blocked":
+            return false;
+          default:
+            return true;
+        }
+      }
+    }
+    if ("*" in ExtensionSettings) {
+      if (ExtensionSettings["*"].installation_mode &&
+          ExtensionSettings["*"].installation_mode == "blocked") {
+        return false;
+      }
+      if ("allowed_types" in ExtensionSettings["*"]) {
+        return ExtensionSettings["*"].allowed_types.includes(addon.type);
+      }
+    }
+    return true;
+  },
+
+  allowedInstallSource(uri) {
+    return InstallSources ? InstallSources.matches(uri) : true;
+  },
 };
 
 let DisallowedFeatures = {};
 let SupportMenu = null;
 let ExtensionPolicies = null;
 let ExtensionSettings = null;
+let InstallSources = null;
 
 /**
  * areEnterpriseOnlyPoliciesAllowed
  *
  * Checks whether the policies marked as enterprise_only in the
  * schema are allowed to run on this browser.
  *
  * This is meant to only allow policies to run on ESR, but in practice
--- a/toolkit/components/enterprisepolicies/nsIEnterprisePolicies.idl
+++ b/toolkit/components/enterprisepolicies/nsIEnterprisePolicies.idl
@@ -1,13 +1,14 @@
 /* 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/. */
 
 #include "nsISupports.idl"
+#include "nsIURI.idl"
 
 [scriptable, uuid(6a568972-cc91-4bf5-963e-3768f3319b8a)]
 interface nsIEnterprisePolicies : nsISupports
 {
   const short UNINITIALIZED = -1;
   const short INACTIVE      = 0;
   const short ACTIVE        = 1;
   const short FAILED        = 2;
@@ -43,9 +44,25 @@ interface nsIEnterprisePolicies : nsISup
    *
    * If there is no policy for the extension, it returns the global policy.
    *
    * If there is no global policy, it returns null.
    *
    * @returns A JS object that settings or null if unavailable.
    */
   jsval getExtensionSettings(in ACString extensionID);
+
+  /**
+   * Uses the whitelist, blacklist and settings to determine if an extension
+   * may be installed.
+   *
+   * @returns A boolean - true of the extension may be installed.
+   */
+  bool mayInstallAddon(in jsval addon);
+
+  /**
+   * Uses install_sources to determine if an extension can be installed
+   * from the given URI.
+   *
+   * @returns A boolean - true of the extension may be installed.
+   */
+  bool allowedInstallSource(in nsIURI uri);
 };
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -1658,25 +1658,63 @@ var AddonManagerInternal = {
     if (!aMimetype || typeof aMimetype != "string")
       throw Components.Exception("aMimetype must be a non-empty string",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     if (!aInstallingPrincipal || !(aInstallingPrincipal instanceof Ci.nsIPrincipal))
       throw Components.Exception("aInstallingPrincipal must be a nsIPrincipal",
                                  Cr.NS_ERROR_INVALID_ARG);
 
+    if (this.isInstallAllowedByPolicy(aInstallingPrincipal, null, true /* explicit */)) {
+      return true;
+    }
+
     let providers = [...this.providers];
     for (let provider of providers) {
       if (callProvider(provider, "supportsMimetype", false, aMimetype) &&
           callProvider(provider, "isInstallAllowed", null, aInstallingPrincipal))
         return true;
     }
     return false;
   },
 
+  /**
+   * Checks whether a particular source is allowed to install add-ons based
+   * on policy.
+   *
+   * @param  aInstallingPrincipal
+   *         The nsIPrincipal that initiated the install
+   * @param  aInstall
+   *         The AddonInstall to be installed
+   * @param  explicit
+   *         If this is set, we only return true if the source is explicitly
+   *         blocked via policy.
+   *
+   * @return boolean
+   *         By default, returns true if the source is blocked by policy
+   *         or there is no policy.
+   *         If explicit is set, only returns true of the source is
+   *         blocked by policy, false otherwise. This is needed for
+   *         handling inverse cases.
+   */
+  isInstallAllowedByPolicy(aInstallingPrincipal, aInstall, explicit) {
+    if (Services.policies) {
+      let extensionSettings = Services.policies.getExtensionSettings("*");
+      if (extensionSettings && extensionSettings.install_sources) {
+        if ((!aInstall || Services.policies.allowedInstallSource(aInstall.sourceURI)) &&
+            (!aInstallingPrincipal || !aInstallingPrincipal.URI ||
+            Services.policies.allowedInstallSource(aInstallingPrincipal.URI))) {
+          return true;
+        }
+        return false;
+      }
+    }
+    return !explicit;
+  },
+
   installNotifyObservers(aTopic, aBrowser, aUri, aInstall, aInstallFn) {
     let info = {
       wrappedJSObject: {
         browser: aBrowser,
         originatingURI: aUri,
         installs: [aInstall],
         install: aInstallFn,
       },
@@ -1804,17 +1842,19 @@ var AddonManagerInternal = {
                                     aInstallingPrincipal.URI, aInstall);
         return;
       } else if (!this.isInstallEnabled(aMimetype)) {
         aInstall.cancel();
 
         this.installNotifyObservers("addon-install-disabled", topBrowser,
                                     aInstallingPrincipal.URI, aInstall);
         return;
-      } else if (aInstallingPrincipal.isNullPrincipal || !aBrowser.contentPrincipal || !aInstallingPrincipal.subsumes(aBrowser.contentPrincipal)) {
+      } else if (aInstallingPrincipal.isNullPrincipal || !aBrowser.contentPrincipal ||
+                 !aInstallingPrincipal.subsumes(aBrowser.contentPrincipal) ||
+                 !this.isInstallAllowedByPolicy(aInstallingPrincipal, aInstall, false /* explicit */)) {
         aInstall.cancel();
 
         this.installNotifyObservers("addon-install-origin-blocked", topBrowser,
                                     aInstallingPrincipal.URI, aInstall);
         return;
       }
 
       // The install may start now depending on the web install listener,
@@ -1850,16 +1890,23 @@ var AddonManagerInternal = {
    * @param  browser
    *         The browser element where the installation was initiated
    * @param  uri
    *         The URI of the page where the installation was initiated
    * @param  install
    *         The AddonInstall to be installed
    */
   installAddonFromAOM(browser, uri, install) {
+    if (!this.isInstallAllowedByPolicy(null, install)) {
+      install.cancel();
+
+      this.installNotifyObservers("addon-install-origin-blocked", browser,
+                                  install.sourceURI, install);
+      return;
+    }
     if (!gStarted)
       throw Components.Exception("AddonManager is not initialized",
                                  Cr.NS_ERROR_NOT_INITIALIZED);
 
     AddonManagerInternal.setupPromptHandler(browser, uri, install, true, "local");
     AddonManagerInternal.startInstall(browser, uri, install);
   },
 
--- a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -654,20 +654,23 @@ class AddonInternal {
     if (!allowPrivateBrowsingByDefault && this.type === "extension" &&
         this.incognito !== "not_allowed" &&
         this.signedState !== AddonManager.SIGNEDSTATE_PRIVILEGED &&
         this.signedState !== AddonManager.SIGNEDSTATE_SYSTEM &&
         !this.location.isBuiltin) {
       permissions |= AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
     }
 
-    if (Services.policies &&
-        !Services.policies.isAllowed(`modify-extension:${this.id}`)) {
-      permissions &= ~AddonManager.PERM_CAN_UNINSTALL;
-      permissions &= ~AddonManager.PERM_CAN_DISABLE;
+    if (Services.policies) {
+      if (!Services.policies.isAllowed(`uninstall-extension:${this.id}`)) {
+        permissions &= ~AddonManager.PERM_CAN_UNINSTALL;
+      }
+      if (!Services.policies.isAllowed(`disable-extension:${this.id}`)) {
+        permissions &= ~AddonManager.PERM_CAN_DISABLE;
+      }
     }
 
     return permissions;
   }
 
   propagateDisabledState(oldAddon) {
     if (oldAddon) {
       this.userDisabled = oldAddon.userDisabled;
@@ -1940,16 +1943,24 @@ this.XPIDatabase = {
     } else {
       let app = aAddon.matchingTargetApplication;
       if (!app) {
         logger.warn(`Add-on ${aAddon.id} is not compatible with target application.`);
         return false;
       }
     }
 
+    if (aAddon.location.isSystem || aAddon.location.isBuiltin) {
+      return true;
+    }
+
+    if (Services.policies && !Services.policies.mayInstallAddon(aAddon)) {
+      return false;
+    }
+
     return true;
   },
 
   /**
    * Synchronously adds an AddonInternal's metadata to the database.
    *
    * @param {AddonInternal} aAddon
    *        AddonInternal to add