Bug 1254194: Add a validator for custom add-on content security policies. r=billm f=aswan
authorKris Maglione <maglione.k@gmail.com>
Sat, 23 Apr 2016 20:41:14 -0700
changeset 294665 58fc5fb221e0eb4dfb23779215873fb870e980c4
parent 294664 f9c7140ccd2e86b8ad75e54db5ae7d3c21c66819
child 294666 f099f37db1e4853395f1e67e9514be28e253ea00
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: Add a validator for custom add-on content security policies. r=billm f=aswan MozReview-Commit-ID: LtBbXBCFc32
caps/nsIAddonPolicyService.idl
toolkit/components/extensions/test/xpcshell/test_csp_validator.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/locales/en-US/chrome/global/extensions.properties
toolkit/locales/jar.mn
toolkit/mozapps/extensions/AddonContentPolicy.cpp
toolkit/mozapps/extensions/AddonContentPolicy.h
--- a/caps/nsIAddonPolicyService.idl
+++ b/caps/nsIAddonPolicyService.idl
@@ -25,8 +25,23 @@ interface nsIAddonPolicyService : nsISup
    */
   boolean extensionURILoadableByAnyone(in nsIURI aURI);
 
   /**
    * Maps an extension URI to the ID of the addon it belongs to.
    */
   AString extensionURIToAddonId(in nsIURI aURI);
 };
+
+/**
+ * This interface exposes functionality related to add-on content policy
+ * enforcement.
+ */
+[scriptable,uuid(7a4fe60b-9131-45f5-83f3-dc63b5d71a5d)]
+interface nsIAddonContentPolicy : nsISupports
+{
+  /**
+   * Checks a custom content security policy string, to ensure that it meets
+   * minimum security requirements. Returns null for valid policies, or a
+   * string describing the error for invalid policies.
+   */
+  AString validateAddonCSP(in AString aPolicyString);
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js
@@ -0,0 +1,85 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const cps = Cc["@mozilla.org/addons/content-policy;1"].getService(Ci.nsIAddonContentPolicy);
+
+add_task(function* test_csp_validator() {
+  let checkPolicy = (policy, expectedResult, message = null) => {
+    do_print(`Checking policy: ${policy}`);
+
+    let result = cps.validateAddonCSP(policy);
+    equal(result, expectedResult);
+  };
+
+  checkPolicy("script-src 'self'; object-src 'self';",
+              null);
+
+  let hash = "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='";
+
+  checkPolicy(`script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` +
+              `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`,
+              null);
+
+  checkPolicy("",
+              "Policy is missing a required 'script-src' directive");
+
+  checkPolicy("object-src 'none';",
+              "Policy is missing a required 'script-src' directive");
+
+
+  checkPolicy("default-src 'self'", null,
+              "A valid default-src should count as a valid script-src or object-src");
+
+  checkPolicy("default-src 'self'; script-src 'self'", null,
+              "A valid default-src should count as a valid script-src or object-src");
+
+  checkPolicy("default-src 'self'; object-src 'self'", null,
+              "A valid default-src should count as a valid script-src or object-src");
+
+
+  checkPolicy("default-src 'self'; script-src http://example.com",
+              "'script-src' directive contains a forbidden http: protocol source",
+              "A valid default-src should not allow an invalid script-src directive");
+
+  checkPolicy("default-src 'self'; object-src http://example.com",
+              "'object-src' directive contains a forbidden http: protocol source",
+              "A valid default-src should not allow an invalid object-src directive");
+
+
+  checkPolicy("script-src 'self';",
+              "Policy is missing a required 'object-src' directive");
+
+  checkPolicy("script-src 'none'; object-src 'none'",
+              "'script-src' must include the source 'self'");
+
+  checkPolicy("script-src 'self'; object-src 'none';",
+              null);
+
+  checkPolicy("script-src 'self' 'unsafe-inline'; object-src 'self';",
+              "'script-src' directive contains a forbidden 'unsafe-inline' keyword");
+
+
+  let directives = ["script-src", "object-src"];
+
+  for (let [directive, other] of [directives, directives.slice().reverse()]) {
+    for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) {
+      checkPolicy(`${directive} 'self' ${src}; ${other} 'self';`,
+                  `https: wildcard sources in '${directive}' directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)`);
+    }
+
+    checkPolicy(`${directive} 'self' https:; ${other} 'self';`,
+                `https: protocol requires a host in '${directive}' directives`);
+
+    checkPolicy(`${directive} 'self' http://example.com; ${other} 'self';`,
+                `'${directive}' directive contains a forbidden http: protocol source`);
+
+    for (let protocol of ["http", "ftp", "meh"]) {
+      checkPolicy(`${directive} 'self' ${protocol}:; ${other} 'self';`,
+                  `'${directive}' directive contains a forbidden ${protocol}: protocol source`);
+    }
+
+    checkPolicy(`${directive} 'self' 'nonce-01234'; ${other} 'self';`,
+                `'${directive}' directive contains a forbidden 'nonce-*' keyword`);
+  }
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -1,12 +1,13 @@
 [DEFAULT]
 head = head.js
 tail =
 firefox-appdir = browser
 skip-if = toolkit == 'gonk'
 
+[test_csp_validator.js]
 [test_locale_data.js]
 [test_locale_converter.js]
 [test_ext_contexts.js]
 [test_ext_json_parser.js]
 [test_ext_schemas.js]
 [test_getAPILevelForWindow.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/locales/en-US/chrome/global/extensions.properties
@@ -0,0 +1,12 @@
+
+csp.error.missing-directive = Policy is missing a required '%S' directive
+
+csp.error.illegal-keyword = '%1$S' directive contains a forbidden %2$S keyword
+
+csp.error.illegal-protocol = '%1$S' directive contains a forbidden %2$S: protocol source
+
+csp.error.missing-host = %2$S: protocol requires a host in '%1$S' directives
+
+csp.error.missing-source = '%1$S' must include the source %2$S
+
+csp.error.illegal-host-wildcard = %2$S: wildcard sources in '%1$S' directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)
--- a/toolkit/locales/jar.mn
+++ b/toolkit/locales/jar.mn
@@ -40,16 +40,17 @@
   locale/@AB_CD@/global/customizeToolbar.properties     (%chrome/global/customizeToolbar.properties)
 #endif
   locale/@AB_CD@/global/datetimepicker.dtd              (%chrome/global/datetimepicker.dtd)
   locale/@AB_CD@/global/dateFormat.properties           (%chrome/global/dateFormat.properties)
   locale/@AB_CD@/global/dialogOverlay.dtd               (%chrome/global/dialogOverlay.dtd)
 #ifndef MOZ_FENNEC
   locale/@AB_CD@/global/editMenuOverlay.dtd             (%chrome/global/editMenuOverlay.dtd)
 #endif
+  locale/@AB_CD@/global/extensions.properties           (%chrome/global/extensions.properties)
   locale/@AB_CD@/global/fallbackMenubar.properties      (%chrome/global/fallbackMenubar.properties)
   locale/@AB_CD@/global/filefield.properties            (%chrome/global/filefield.properties)
   locale/@AB_CD@/global/filepicker.dtd                  (%chrome/global/filepicker.dtd)
   locale/@AB_CD@/global/filepicker.properties           (%chrome/global/filepicker.properties)
 #ifndef MOZ_FENNEC
   locale/@AB_CD@/global/findbar.dtd                     (%chrome/global/findbar.dtd)
   locale/@AB_CD@/global/findbar.properties              (%chrome/global/findbar.properties)
   locale/@AB_CD@/global/finddialog.dtd                  (%chrome/global/finddialog.dtd)
--- a/toolkit/mozapps/extensions/AddonContentPolicy.cpp
+++ b/toolkit/mozapps/extensions/AddonContentPolicy.cpp
@@ -1,45 +1,56 @@
 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* vim: set ts=8 sts=2 et sw=2 tw=80: */
 /* 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 "AddonContentPolicy.h"
 
+#include "mozilla/dom/nsCSPUtils.h"
 #include "nsCOMPtr.h"
 #include "nsContentPolicyUtils.h"
 #include "nsContentTypeParser.h"
 #include "nsContentUtils.h"
 #include "nsIConsoleService.h"
+#include "nsIContentSecurityPolicy.h"
 #include "nsIContent.h"
 #include "nsIDocument.h"
+#include "nsIEffectiveTLDService.h"
 #include "nsIScriptError.h"
+#include "nsIStringBundle.h"
+#include "nsIUUIDGenerator.h"
 #include "nsIURI.h"
+#include "nsNetCID.h"
+#include "nsNetUtil.h"
+
+using namespace mozilla;
 
 /* Enforces content policies for WebExtension scopes. Currently:
  *
  *  - Prevents loading scripts with a non-default JavaScript version.
+ *  - Checks custom content security policies for sufficiently stringent
+ *    script-src and object-src directives.
  */
 
 #define VERSIONED_JS_BLOCKED_MESSAGE \
   MOZ_UTF16("Versioned JavaScript is a non-standard, deprecated extension, and is ") \
   MOZ_UTF16("not supported in WebExtension code. For alternatives, please see: ") \
   MOZ_UTF16("https://developer.mozilla.org/Add-ons/WebExtensions/Tips")
 
 AddonContentPolicy::AddonContentPolicy()
 {
 }
 
 AddonContentPolicy::~AddonContentPolicy()
 {
 }
 
-NS_IMPL_ISUPPORTS(AddonContentPolicy, nsIContentPolicy)
+NS_IMPL_ISUPPORTS(AddonContentPolicy, nsIContentPolicy, nsIAddonContentPolicy)
 
 static nsresult
 GetWindowIDFromContext(nsISupports* aContext, uint64_t *aResult)
 {
   NS_ENSURE_TRUE(aContext, NS_ERROR_FAILURE);
 
   nsCOMPtr<nsIContent> content = do_QueryInterface(aContext);
   NS_ENSURE_TRUE(content, NS_ERROR_FAILURE);
@@ -75,16 +86,19 @@ LogMessage(const nsAString &aMessage, ns
 
   nsCOMPtr<nsIConsoleService> console = do_GetService(NS_CONSOLESERVICE_CONTRACTID);
   NS_ENSURE_TRUE(console, NS_ERROR_OUT_OF_MEMORY);
 
   console->LogMessage(error);
   return NS_OK;
 }
 
+
+// Content policy enforcement:
+
 NS_IMETHODIMP
 AddonContentPolicy::ShouldLoad(uint32_t aContentType,
                                nsIURI* aContentLocation,
                                nsIURI* aRequestOrigin,
                                nsISupports* aContext,
                                const nsACString& aMimeTypeGuess,
                                nsISupports* aExtra,
                                nsIPrincipal* aRequestPrincipal,
@@ -138,8 +152,328 @@ AddonContentPolicy::ShouldProcess(uint32
                                   int16_t* aShouldProcess)
 {
   MOZ_ASSERT(aContentType == nsContentUtils::InternalContentPolicyTypeToExternal(aContentType),
              "We should only see external content policy types here.");
 
   *aShouldProcess = nsIContentPolicy::ACCEPT;
   return NS_OK;
 }
+
+
+// CSP Validation:
+
+static const char* allowedSchemes[] = {
+  "blob",
+  "filesystem",
+  nullptr
+};
+
+static const char* allowedHostSchemes[] = {
+  "https",
+  "moz-extension",
+  nullptr
+};
+
+/**
+ * Validates a CSP directive to ensure that it is sufficiently stringent.
+ * In particular, ensures that:
+ *
+ *  - No remote sources are allowed other than from https: schemes
+ *
+ *  - No remote sources specify host wildcards for generic domains
+ *    (*.blogspot.com, *.com, *)
+ *
+ *  - All remote sources and local extension sources specify a host
+ *
+ *  - No scheme sources are allowed other than blob:, filesystem:,
+ *    moz-extension:, and https:
+ *
+ *  - No keyword sources are allowed other than 'none', 'self', 'unsafe-eval',
+ *    and hash sources.
+ */
+class CSPValidator final : public nsCSPSrcVisitor {
+  public:
+    CSPValidator(nsAString& aURL, CSPDirective aDirective, bool aDirectiveRequired = true) :
+      mURL(aURL),
+      mDirective(CSP_CSPDirectiveToString(aDirective)),
+      mFoundSelf(false)
+    {
+      // Start with the default error message for a missing directive, since no
+      // visitors will be called if the directive isn't present.
+      if (aDirectiveRequired) {
+        FormatError("csp.error.missing-directive");
+      }
+    }
+
+    // Visitors
+
+    bool visitSchemeSrc(const nsCSPSchemeSrc& src) override
+    {
+      nsAutoString scheme;
+      src.getScheme(scheme);
+
+      if (SchemeInList(scheme, allowedHostSchemes)) {
+        FormatError("csp.error.missing-host", scheme);
+        return false;
+      }
+      if (!SchemeInList(scheme, allowedSchemes)) {
+        FormatError("csp.error.illegal-protocol", scheme);
+        return false;
+      }
+      return true;
+    };
+
+    bool visitHostSrc(const nsCSPHostSrc& src) override
+    {
+      nsAutoString scheme, host;
+
+      src.getScheme(scheme);
+      src.getHost(host);
+
+      if (scheme.LowerCaseEqualsLiteral("https")) {
+        if (!HostIsAllowed(host)) {
+          FormatError("csp.error.illegal-host-wildcard", scheme);
+          return false;
+        }
+      } else if (scheme.LowerCaseEqualsLiteral("moz-extension")) {
+        // The CSP parser silently converts 'self' keywords to the origin
+        // URL, so we need to reconstruct the URL to see if it was present.
+        if (!mFoundSelf) {
+          nsAutoString url(MOZ_UTF16("moz-extension://"));
+          url.Append(host);
+
+          mFoundSelf = url.Equals(mURL);
+        }
+
+        if (host.IsEmpty() || host.EqualsLiteral("*")) {
+          FormatError("csp.error.missing-host", scheme);
+          return false;
+        }
+      } else if (!SchemeInList(scheme, allowedSchemes)) {
+        FormatError("csp.error.illegal-protocol", scheme);
+        return false;
+      }
+
+      return true;
+    };
+
+    bool visitKeywordSrc(const nsCSPKeywordSrc& src) override
+    {
+      switch (src.getKeyword()) {
+      case CSP_NONE:
+      case CSP_SELF:
+      case CSP_UNSAFE_EVAL:
+        return true;
+
+      default:
+        NS_ConvertASCIItoUTF16 keyword(CSP_EnumToKeyword(src.getKeyword()));
+
+        FormatError("csp.error.illegal-keyword", keyword);
+        return false;
+      }
+    };
+
+    bool visitNonceSrc(const nsCSPNonceSrc& src) override
+    {
+      FormatError("csp.error.illegal-keyword", NS_LITERAL_STRING("'nonce-*'"));
+      return false;
+    };
+
+    bool visitHashSrc(const nsCSPHashSrc& src) override
+    {
+      return true;
+    };
+
+    // Accessors
+
+    inline nsAString& GetError()
+    {
+      return mError;
+    };
+
+    inline bool FoundSelf()
+    {
+      return mFoundSelf;
+    };
+
+
+    // Formatters
+
+    template <typename... T>
+    inline void FormatError(const char* aName, const T ...aParams)
+    {
+      const char16_t* params[] = { mDirective.get(), aParams.get()... };
+      FormatErrorParams(aName, params, MOZ_ARRAY_LENGTH(params));
+    };
+
+  private:
+    // Validators
+
+    bool HostIsAllowed(nsAString& host)
+    {
+      if (host.First() == '*') {
+        if (host.EqualsLiteral("*") || host[1] != '.') {
+          return false;
+        }
+
+        host.Cut(0, 2);
+
+        nsCOMPtr<nsIEffectiveTLDService> tldService =
+          do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID);
+
+        if (!tldService) {
+          return false;
+        }
+
+        NS_ConvertUTF16toUTF8 cHost(host);
+        nsAutoCString publicSuffix;
+
+        nsresult rv = tldService->GetPublicSuffixFromHost(cHost, publicSuffix);
+
+        return NS_SUCCEEDED(rv) && !cHost.Equals(publicSuffix);
+      }
+
+      return true;
+    };
+
+    bool SchemeInList(nsAString& scheme, const char** schemes)
+    {
+      for (; *schemes; schemes++) {
+        if (scheme.LowerCaseEqualsASCII(*schemes)) {
+          return true;
+        }
+      }
+      return false;
+    };
+
+
+    // Formatters
+
+    already_AddRefed<nsIStringBundle>
+    GetStringBundle()
+    {
+      nsCOMPtr<nsIStringBundleService> sbs =
+        mozilla::services::GetStringBundleService();
+      NS_ENSURE_TRUE(sbs, nullptr);
+
+      nsCOMPtr<nsIStringBundle> stringBundle;
+      sbs->CreateBundle("chrome://global/locale/extensions.properties",
+                        getter_AddRefs(stringBundle));
+
+      return stringBundle.forget();
+    };
+
+    void FormatErrorParams(const char* aName, const char16_t** aParams, int32_t aLength)
+    {
+      nsresult rv = NS_ERROR_FAILURE;
+
+      nsCOMPtr<nsIStringBundle> stringBundle = GetStringBundle();
+
+      if (stringBundle) {
+        NS_ConvertASCIItoUTF16 name(aName);
+
+        rv = stringBundle->FormatStringFromName(name.get(), aParams, aLength,
+                                                getter_Copies(mError));
+      }
+
+      if (NS_WARN_IF(NS_FAILED(rv))) {
+        mError.AssignLiteral("An unexpected error occurred");
+      }
+    };
+
+
+    // Data members
+
+    nsAutoString mURL;
+    NS_ConvertASCIItoUTF16 mDirective;
+    nsXPIDLString mError;
+
+    bool mFoundSelf;
+};
+
+/**
+ * Validates a custom content security policy string for use by an add-on.
+ * In particular, ensures that:
+ *
+ *  - Both object-src and script-src directives are present, and meet
+ *    the policies required by the CSPValidator class
+ *
+ *  - The script-src directive includes the source 'self'
+ */
+NS_IMETHODIMP
+AddonContentPolicy::ValidateAddonCSP(const nsAString& aPolicyString,
+                                     nsAString& aResult)
+{
+  nsresult rv;
+
+  // Validate against a randomly-generated extension origin.
+  // There is no add-on-specific behavior in the CSP code, beyond the ability
+  // for add-ons to specify a custom policy, but the parser requires a valid
+  // origin in order to operate correctly.
+  nsAutoString url(MOZ_UTF16("moz-extension://"));
+  {
+    nsCOMPtr<nsIUUIDGenerator> uuidgen = services::GetUUIDGenerator();
+    NS_ENSURE_TRUE(uuidgen, NS_ERROR_FAILURE);
+
+    nsID id;
+    rv = uuidgen->GenerateUUIDInPlace(&id);
+    NS_ENSURE_SUCCESS(rv, rv);
+
+    char idString[NSID_LENGTH];
+    id.ToProvidedString(idString);
+
+    MOZ_RELEASE_ASSERT(idString[0] == '{' && idString[NSID_LENGTH - 2] == '}',
+                       "UUID generator did not return a valid UUID");
+
+    url.AppendASCII(idString + 1, NSID_LENGTH - 3);
+  }
+
+
+  RefPtr<BasePrincipal> principal =
+    BasePrincipal::CreateCodebasePrincipal(NS_ConvertUTF16toUTF8(url));
+
+  nsCOMPtr<nsIContentSecurityPolicy> csp;
+  rv = principal->EnsureCSP(nullptr, getter_AddRefs(csp));
+  NS_ENSURE_SUCCESS(rv, rv);
+
+
+  csp->AppendPolicy(aPolicyString, false, false);
+
+  const nsCSPPolicy* policy = csp->GetPolicy(0);
+  if (!policy) {
+    CSPValidator validator(url, nsIContentSecurityPolicy::SCRIPT_SRC_DIRECTIVE);
+    aResult.Assign(validator.GetError());
+    return NS_OK;
+  }
+
+  bool haveValidDefaultSrc = false;
+  {
+    CSPDirective directive = nsIContentSecurityPolicy::DEFAULT_SRC_DIRECTIVE;
+    CSPValidator validator(url, directive);
+
+    haveValidDefaultSrc = policy->visitDirectiveSrcs(directive, &validator);
+  }
+
+  aResult.SetIsVoid(true);
+  {
+    CSPDirective directive = nsIContentSecurityPolicy::SCRIPT_SRC_DIRECTIVE;
+    CSPValidator validator(url, directive, !haveValidDefaultSrc);
+
+    if (!policy->visitDirectiveSrcs(directive, &validator)) {
+      aResult.Assign(validator.GetError());
+    } else if (!validator.FoundSelf()) {
+      validator.FormatError("csp.error.missing-source", NS_LITERAL_STRING("'self'"));
+      aResult.Assign(validator.GetError());
+    }
+  }
+
+  if (aResult.IsVoid()) {
+    CSPDirective directive = nsIContentSecurityPolicy::OBJECT_SRC_DIRECTIVE;
+    CSPValidator validator(url, directive, !haveValidDefaultSrc);
+
+    if (!policy->visitDirectiveSrcs(directive, &validator)) {
+      aResult.Assign(validator.GetError());
+    }
+  }
+
+  return NS_OK;
+}
--- a/toolkit/mozapps/extensions/AddonContentPolicy.h
+++ b/toolkit/mozapps/extensions/AddonContentPolicy.h
@@ -1,19 +1,22 @@
 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* vim: set ts=8 sts=2 et sw=2 tw=80: */
 /* 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 "nsIContentPolicy.h"
+#include "nsIAddonPolicyService.h"
 
-class AddonContentPolicy : public nsIContentPolicy
+class AddonContentPolicy : public nsIContentPolicy,
+                           public nsIAddonContentPolicy
 {
 protected:
   virtual ~AddonContentPolicy();
 
 public:
   AddonContentPolicy();
 
   NS_DECL_ISUPPORTS
   NS_DECL_NSICONTENTPOLICY
+  NS_DECL_NSIADDONCONTENTPOLICY
 };