Bug 1254194: [webext] Allow extensions to register custom content security policies. r=billm f=aswan
authorKris Maglione <maglione.k@gmail.com>
Sat, 23 Apr 2016 21:29:15 -0700
changeset 294668 40310d2455d5f5dba0c4de2a192808b57509b942
parent 294667 64a14b5509fcbc1da50eddabcd526cbfe0e57e00
child 294669 701c0a43f593039d47d385ee103f060a8cd3e570
push id75639
push usermaglione.k@gmail.com
push dateSun, 24 Apr 2016 04:34:45 +0000
treeherdermozilla-inbound@bd06fa422194 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbillm
bugs1254194
milestone48.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 1254194: [webext] Allow extensions to register custom content security policies. r=billm f=aswan MozReview-Commit-ID: 8L6ZsyDjIpf
browser/app/profile/firefox.js
caps/nsIAddonPolicyService.idl
mobile/android/app/mobile.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionManagement.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/schemas/manifest.json
toolkit/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js
toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/components/utils/simpleServices.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -91,16 +91,20 @@ pref("extensions.hotfix.certs.2.sha1Fing
 
 // Check AUS for system add-on updates.
 pref("extensions.systemAddon.update.url", "https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml");
 
 // Disable add-ons that are not installed by the user in all scopes by default.
 // See the SCOPE constants in AddonManager.jsm for values to use here.
 pref("extensions.autoDisableScopes", 15);
 
+// Add-on content security policies.
+pref("extensions.webextensions.base-content-security-policy", "script-src 'self' https://* moz-extension: blob: filesystem: 'unsafe-eval' 'unsafe-inline'; object-src 'self' https://* moz-extension: blob: filesystem:;");
+pref("extensions.webextensions.default-content-security-policy", "script-src 'self'; object-src 'self';");
+
 // Require signed add-ons by default
 pref("xpinstall.signatures.required", true);
 pref("xpinstall.signatures.devInfoURL", "https://wiki.mozilla.org/Addons/Extension_Signing");
 
 // Dictionary download preference
 pref("browser.dictionaries.download.url", "https://addons.mozilla.org/%LOCALE%/firefox/dictionaries/");
 
 // At startup, should we check to see if the installation
--- a/caps/nsIAddonPolicyService.idl
+++ b/caps/nsIAddonPolicyService.idl
@@ -10,16 +10,35 @@
 /**
  * This interface allows the security manager to query custom per-addon security
  * policy.
  */
 [scriptable,uuid(8a034ef9-9d14-4c5d-8319-06c1ab574baa)]
 interface nsIAddonPolicyService : nsISupports
 {
   /**
+   * Returns the base content security policy, which is applied to all
+   * extension documents, in addition to any custom policies.
+   */
+  readonly attribute AString baseCSP;
+
+  /**
+   * Returns the default content security policy which applies to extension
+   * documents which do not specify any custom policies.
+   */
+  readonly attribute AString defaultCSP;
+
+  /**
+   * Returns the content security policy which applies to documents belonging
+   * to the extension with the given ID. This may be either a custom policy,
+   * if one was supplied, or the default policy if one was not.
+   */
+  AString getAddonCSP(in AString aAddonId);
+
+  /**
    * Returns true if unprivileged code associated with the given addon may load
    * data from |aURI|.
    */
   boolean addonMayLoadURI(in AString aAddonId, in nsIURI aURI);
 
   /**
    * Returns true if a given extension:// URI is web-accessible.
    */
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -258,16 +258,20 @@ pref("services.kinto.gfx.checked", 0);
 pref("services.kinto.update_enabled", false);
 #else
 pref("services.kinto.update_enabled", true);
 #endif
 
 /* Don't let XPIProvider install distribution add-ons; we do our own thing on mobile. */
 pref("extensions.installDistroAddons", false);
 
+// Add-on content security policies.
+pref("extensions.webextensions.base-content-security-policy", "script-src 'self' https://* moz-extension: blob: filesystem: 'unsafe-eval' 'unsafe-inline'; object-src 'self' https://* moz-extension: blob: filesystem:;");
+pref("extensions.webextensions.default-content-security-policy", "script-src 'self'; object-src 'self';");
+
 /* block popups by default, and notify the user about blocked popups */
 pref("dom.disable_open_during_load", true);
 pref("privacy.popups.showBrowserMessage", true);
 
 /* disable opening windows with the dialog feature */
 pref("dom.disable_window_open_dialog_feature", true);
 pref("dom.disable_window_showModalDialog", true);
 pref("dom.disable_window_print", true);
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1391,23 +1391,21 @@ Extension.prototype = extend(Object.crea
       let match = Locale.findClosestLocale(localeList);
       locale = match ? match.name : this.defaultLocale;
     }
 
     return ExtensionData.prototype.initLocale.call(this, locale);
   }),
 
   startup() {
-    try {
+    let started = false;
+    return this.readManifest().then(() => {
       ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this);
-    } catch (e) {
-      return Promise.reject(e);
-    }
+      started = true;
 
-    return this.readManifest().then(() => {
       if (!this.hasShutdown) {
         return this.initLocale();
       }
     }).then(() => {
       if (this.errors.length) {
         // b2g add-ons generate manifest errors that we've silently
         // ignoring prior to adding this check.
         if (!this.rootURI.schemeIs("app")) {
@@ -1423,17 +1421,19 @@ Extension.prototype = extend(Object.crea
 
       Management.emit("startup", this);
 
       return this.runManifest(this.manifest);
     }).catch(e => {
       dump(`Extension error: ${e.message} ${e.filename || e.fileName}:${e.lineNumber} :: ${e.stack || new Error().stack}\n`);
       Cu.reportError(e);
 
-      ExtensionManagement.shutdownExtension(this.uuid);
+      if (started) {
+        ExtensionManagement.shutdownExtension(this.uuid);
+      }
 
       this.cleanupGeneratedFile();
 
       throw e;
     });
   },
 
   cleanupGeneratedFile() {
--- a/toolkit/components/extensions/ExtensionManagement.jsm
+++ b/toolkit/components/extensions/ExtensionManagement.jsm
@@ -155,24 +155,26 @@ var Service = {
 
     let handler = Services.io.getProtocolHandler("moz-extension");
     handler.QueryInterface(Ci.nsISubstitutingProtocolHandler);
     handler.setSubstitution(uuid, uri);
 
     this.uuidMap.set(uuid, extension);
     this.aps.setAddonLoadURICallback(extension.id, this.checkAddonMayLoad.bind(this, extension));
     this.aps.setAddonLocalizeCallback(extension.id, extension.localize.bind(extension));
+    this.aps.setAddonCSP(extension.id, extension.manifest.content_security_policy);
   },
 
   // Called when an extension is unloaded.
   shutdownExtension(uuid) {
     let extension = this.uuidMap.get(uuid);
     this.uuidMap.delete(uuid);
     this.aps.setAddonLoadURICallback(extension.id, null);
     this.aps.setAddonLocalizeCallback(extension.id, null);
+    this.aps.setAddonCSP(extension.id, null);
 
     let handler = Services.io.getProtocolHandler("moz-extension");
     handler.QueryInterface(Ci.nsISubstitutingProtocolHandler);
     handler.setSubstitution(uuid, null);
   },
 
   // Return true if the given URI can be loaded from arbitrary web
   // content. The manifest.json |web_accessible_resources| directive
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -4,31 +4,35 @@
 
 "use strict";
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   instanceOf,
 } = ExtensionUtils;
 
+XPCOMUtils.defineLazyServiceGetter(this, "contentPolicyService",
+                                   "@mozilla.org/addons/content-policy;1",
+                                   "nsIAddonContentPolicy");
+
 this.EXPORTED_SYMBOLS = ["Schemas"];
 
 /* globals Schemas, URL */
 
-Cu.import("resource://gre/modules/NetUtil.jsm");
-
-Cu.importGlobalProperties(["URL"]);
-
 function readJSON(url) {
   return new Promise((resolve, reject) => {
     NetUtil.asyncFetch({uri: url, loadUsingSystemPrincipal: true}, (inputStream, status) => {
       if (!Components.isSuccessCode(status)) {
         reject(new Error(status));
         return;
       }
       try {
@@ -252,16 +256,24 @@ const FORMATS = {
       } catch (e) {
         return FORMATS.relativeUrl(string, context);
       }
     }
 
     throw new SyntaxError(`String ${JSON.stringify(string)} must be a relative URL`);
   },
 
+  contentSecurityPolicy(string, context) {
+    let error = contentPolicyService.validateAddonCSP(string);
+    if (error != null) {
+      throw new SyntaxError(error);
+    }
+    return string;
+  },
+
   date(string, context) {
     // A valid ISO 8601 timestamp.
     const PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([-+]\d{2}:?\d{2})))?$/;
     if (!PATTERN.test(string)) {
       throw new Error(`Invalid date string ${string}`);
     }
     // Our pattern just checks the format, we could still have invalid
     // values (e.g., month=99 or month=02 and day=31).  Let the Date
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -144,16 +144,23 @@
           },
 
           "content_scripts": {
             "type": "array",
             "optional": true,
             "items": { "$ref": "ContentScript" }
           },
 
+          "content_security_policy": {
+            "type": "string",
+            "optional": true,
+            "format": "contentSecurityPolicy",
+            "onError": "warn"
+          },
+
           "permissions": {
             "type": "array",
             "items": {
               "choices": [
                 { "$ref": "Permission" },
                 {
                   "type": "string",
                   "deprecated": "Unknown permission ${value}"
--- a/toolkit/components/extensions/test/xpcshell/head.js
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -5,10 +5,47 @@ const {classes: Cc, interfaces: Ci, util
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
+                                  "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
+
+/* exported normalizeManifest */
+
+let BASE_MANIFEST = {
+  "applications": {"gecko": {"id": "test@web.ext"}},
+
+  "manifest_version": 2,
+
+  "name": "name",
+  "version": "0",
+};
+
+function* normalizeManifest(manifest, baseManifest = BASE_MANIFEST) {
+  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
+
+  yield Management.lazyInit();
+
+  let errors = [];
+  let context = {
+    url: null,
+
+    logError: error => {
+      errors.push(error);
+    },
+
+    preprocessors: {},
+  };
+
+  manifest = Object.assign({}, baseManifest, manifest);
+
+  let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
+  normalized.errors = errors;
+
+  return normalized;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js
@@ -0,0 +1,38 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+
+const ADDON_ID = "test@web.extension";
+
+const aps = Cc["@mozilla.org/addons/policy-service;1"]
+  .getService(Ci.nsIAddonPolicyService).wrappedJSObject;
+
+do_register_cleanup(() => {
+  aps.setAddonCSP(ADDON_ID, null);
+});
+
+add_task(function* test_addon_csp() {
+  equal(aps.baseCSP, Preferences.get("extensions.webextensions.base-content-security-policy"),
+        "Expected base CSP value");
+
+  equal(aps.defaultCSP, Preferences.get("extensions.webextensions.default-content-security-policy"),
+        "Expected default CSP value");
+
+  equal(aps.getAddonCSP(ADDON_ID), aps.defaultCSP,
+        "CSP for unknown add-on ID should be the default CSP");
+
+
+  const CUSTOM_POLICY = "script-src: 'self' https://xpcshell.test.custom.csp; object-src: 'none'";
+
+  aps.setAddonCSP(ADDON_ID, CUSTOM_POLICY);
+
+  equal(aps.getAddonCSP(ADDON_ID), CUSTOM_POLICY, "CSP should point to add-on's custom policy");
+
+
+  aps.setAddonCSP(ADDON_ID, null);
+
+  equal(aps.getAddonCSP(ADDON_ID), aps.defaultCSP,
+        "CSP should revert to default when set to null");
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
@@ -0,0 +1,30 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+
+add_task(function* test_manifest_csp() {
+  let normalized = yield normalizeManifest({
+    "content_security_policy": "script-src 'self'; object-src 'none'",
+  });
+
+  equal(normalized.error, undefined, "Should not have an error");
+  equal(normalized.errors.length, 0, "Should not have warnings");
+  equal(normalized.value.content_security_policy,
+        "script-src 'self'; object-src 'none'",
+        "Should have the expected poilcy string");
+
+
+  normalized = yield normalizeManifest({
+    "content_security_policy": "object-src 'none'",
+  });
+
+  equal(normalized.error, undefined, "Should not have an error");
+
+  Assert.deepEqual(normalized.errors,
+                   ["Error processing content_security_policy: SyntaxError: Policy is missing a required 'script-src' directive"],
+                   "Should have the expected warning");
+
+  equal(normalized.value.content_security_policy, null,
+        "Invalid policy string should be omitted");
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -1,13 +1,15 @@
 [DEFAULT]
 head = head.js
 tail =
 firefox-appdir = browser
 skip-if = toolkit == 'gonk'
 
+[test_csp_custom_policies.js]
 [test_csp_validator.js]
 [test_locale_data.js]
 [test_locale_converter.js]
 [test_ext_contexts.js]
 [test_ext_json_parser.js]
+[test_ext_manifest_content_security_policy.js]
 [test_ext_schemas.js]
 [test_getAPILevelForWindow.js]
--- a/toolkit/components/utils/simpleServices.js
+++ b/toolkit/components/utils/simpleServices.js
@@ -57,24 +57,44 @@ RemoteTagServiceService.prototype = {
 
     return "generic";
   }
 };
 
 function AddonPolicyService()
 {
   this.wrappedJSObject = this;
+  this.cspStrings = new Map();
   this.mayLoadURICallbacks = new Map();
   this.localizeCallbacks = new Map();
+
+  XPCOMUtils.defineLazyPreferenceGetter(
+    this, "baseCSP", "extensions.webextensions.base-content-security-policy",
+    "script-src 'self' https://* moz-extension: blob: filesystem: 'unsafe-eval' 'unsafe-inline'; " +
+    "object-src 'self' https://* moz-extension: blob: filesystem:;");
+
+  XPCOMUtils.defineLazyPreferenceGetter(
+    this, "defaultCSP", "extensions.webextensions.default-content-security-policy",
+    "script-src 'self'; object-src 'self';");
 }
 
 AddonPolicyService.prototype = {
   classID: Components.ID("{89560ed3-72e3-498d-a0e8-ffe50334d7c5}"),
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIAddonPolicyService]),
 
+  /**
+   * Returns the content security policy which applies to documents belonging
+   * to the extension with the given ID. This may be either a custom policy,
+   * if one was supplied, or the default policy if one was not.
+   */
+  getAddonCSP(aAddonId) {
+    let csp = this.cspStrings.get(aAddonId);
+    return csp || this.defaultCSP;
+  },
+
   /*
    * Invokes a callback (if any) associated with the addon to determine whether
    * unprivileged code running within the addon is allowed to perform loads from
    * the given URI.
    *
    * @see nsIAddonPolicyService.addonMayLoadURI
    */
   addonMayLoadURI(aAddonId, aURI) {
@@ -132,16 +152,29 @@ AddonPolicyService.prototype = {
     if (aCallback) {
       this.mayLoadURICallbacks.set(aAddonId, aCallback);
     } else {
       this.mayLoadURICallbacks.delete(aAddonId);
     }
   },
 
   /*
+   * Sets the custom CSP string to be used for the add-on. Not accessible over
+   * XPCOM - callers should use .wrappedJSObject on the service to call it
+   * directly.
+   */
+  setAddonCSP(aAddonId, aCSPString) {
+    if (aCSPString) {
+      this.cspStrings.set(aAddonId, aCSPString);
+    } else {
+      this.cspStrings.delete(aAddonId);
+    }
+  },
+
+  /*
    * Sets the callbacks used by the stream converter service to localize
    * add-on resources.
    */
   setAddonLocalizeCallback(aAddonId, aCallback) {
     if (aCallback) {
       this.localizeCallbacks.set(aAddonId, aCallback);
     } else {
       this.localizeCallbacks.delete(aAddonId);