Bug 1254194: [webext] Allow extensions to register custom content security policies. r?billm f?aswan
MozReview-Commit-ID: 8L6ZsyDjIpf
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -85,16 +85,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'; 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
@@ -251,16 +251,20 @@ pref("services.kinto.onecrl.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'; 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
@@ -1201,23 +1201,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")) {
@@ -1233,17 +1231,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(uri) {
return new Promise((resolve, reject) => {
NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
if (!Components.isSuccessCode(status)) {
reject(new Error(status));
return;
}
try {
@@ -245,16 +249,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
@@ -1,10 +1,49 @@
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Extension",
+ "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,10 +1,12 @@
[DEFAULT]
head = head.js
tail =
firefox-appdir = browser
skip-if = toolkit == 'android' || toolkit == 'gonk'
+[test_csp_custom_policies.js]
[test_csp_validator.js]
[test_locale_data.js]
[test_locale_converter.js]
+[test_ext_manifest_content_security_policy.js]
[test_ext_schemas.js]
--- a/toolkit/components/utils/simpleServices.js
+++ b/toolkit/components/utils/simpleServices.js
@@ -49,24 +49,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'; " +
+ "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) {
@@ -124,16 +144,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);