Bug 1581611 Part 2: apply content script csp r=robwu,ckerschb
authorShane Caraveo <scaraveo@mozilla.com>
Fri, 01 Nov 2019 06:03:13 +0000
changeset 500097 53390b20df642d370124457623822d5dcde5a708
parent 500096 a56f917583a6d6942cc4b984dfb2d87146665b70
child 500098 23c113d65b48353d5ce085fd0c7f67d3604bd244
push id99395
push userscaraveo@mozilla.com
push dateFri, 01 Nov 2019 06:59:57 +0000
treeherderautoland@23c113d65b48 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrobwu, ckerschb
bugs1581611
milestone72.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 1581611 Part 2: apply content script csp r=robwu,ckerschb Manifest V3 functionality. This applies CSP on the webextension content scripts using either a default csp or an extension provided csp. It will remain pref'd off but is available for developers to test against, as well as for future validation of chrome compatibility. Differential Revision: https://phabricator.services.mozilla.com/D48107
js/xpconnect/src/Sandbox.cpp
modules/libpref/init/StaticPrefList.yaml
toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js
toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
--- a/js/xpconnect/src/Sandbox.cpp
+++ b/js/xpconnect/src/Sandbox.cpp
@@ -59,19 +59,23 @@
 #include "mozilla/dom/TextDecoderBinding.h"
 #include "mozilla/dom/TextEncoderBinding.h"
 #include "mozilla/dom/UnionConversions.h"
 #include "mozilla/dom/URLBinding.h"
 #include "mozilla/dom/URLSearchParamsBinding.h"
 #include "mozilla/dom/XMLHttpRequest.h"
 #include "mozilla/dom/XMLSerializerBinding.h"
 #include "mozilla/dom/FormDataBinding.h"
+#include "mozilla/dom/nsCSPContext.h"
 #include "mozilla/BasePrincipal.h"
 #include "mozilla/DeferredFinalize.h"
+#include "mozilla/ExtensionPolicyService.h"
 #include "mozilla/NullPrincipal.h"
+#include "mozilla/ResultExtensions.h"
+#include "mozilla/StaticPrefs_extensions.h"
 
 using namespace mozilla;
 using namespace JS;
 using namespace xpc;
 
 using mozilla::dom::DestroyProtoAndIfaceCache;
 using mozilla::dom::IndexedDatabaseManager;
 
@@ -1013,16 +1017,85 @@ bool xpc::GlobalProperties::DefineInSand
 
   if (indexedDB && !(IndexedDatabaseManager::ResolveSandboxBinding(cx) &&
                      IndexedDatabaseManager::DefineIndexedDB(cx, obj)))
     return false;
 
   return Define(cx, obj);
 }
 
+/**
+ * If enabled, apply the extension base CSP, then apply the
+ * content script CSP which will either be a default or one
+ * provided by the extension in its manifest.
+ */
+nsresult ApplyAddonContentScriptCSP(nsISupports* prinOrSop) {
+  if (!StaticPrefs::extensions_content_script_csp_enabled()) {
+    return NS_OK;
+  }
+  nsCOMPtr<nsIPrincipal> principal = do_QueryInterface(prinOrSop);
+  if (!principal) {
+    return NS_OK;
+  }
+
+  auto* basePrin = BasePrincipal::Cast(principal);
+  // We only get an addonPolicy if the principal is an
+  // expanded principal with an extension principal in it.
+  auto* addonPolicy = basePrin->ContentScriptAddonPolicy();
+  if (!addonPolicy) {
+    return NS_OK;
+  }
+
+  nsString url;
+  MOZ_TRY_VAR(url, addonPolicy->GetURL(NS_LITERAL_STRING("")));
+
+  nsCOMPtr<nsIURI> selfURI;
+  MOZ_TRY(NS_NewURI(getter_AddRefs(selfURI), url));
+
+  nsAutoString baseCSP;
+  MOZ_ALWAYS_SUCCEEDS(
+      ExtensionPolicyService::GetSingleton().GetBaseCSP(baseCSP));
+
+  // If we got here, we're definitly an expanded principal.
+  auto expanded = basePrin->As<ExpandedPrincipal>();
+  nsCOMPtr<nsIContentSecurityPolicy> csp;
+
+#ifdef MOZ_DEBUG
+  // Bug 1548468: Move CSP off ExpandedPrincipal
+  expanded->GetCsp(getter_AddRefs(csp));
+  if (csp) {
+    uint32_t count = 0;
+    csp->GetPolicyCount(&count);
+    if (count > 0) {
+      // Ensure that the policy was not already added.
+      nsAutoString parsedPolicyStr;
+      for (uint32_t i = 0; i < count; i++) {
+        csp->GetPolicyString(i, parsedPolicyStr);
+        MOZ_ASSERT(!parsedPolicyStr.Equals(baseCSP));
+      }
+    }
+  }
+#endif
+
+  csp = new nsCSPContext();
+  MOZ_TRY(
+      csp->SetRequestContextWithPrincipal(expanded, selfURI, EmptyString(), 0));
+
+  bool reportOnly = StaticPrefs::extensions_content_script_csp_report_only();
+
+  MOZ_TRY(csp->AppendPolicy(baseCSP, reportOnly, false));
+
+  // Set default or extension provided csp.
+  const nsAString& contentScriptCSP = addonPolicy->ContentScriptCSP();
+  MOZ_TRY(csp->AppendPolicy(contentScriptCSP, reportOnly, false));
+
+  expanded->SetCsp(csp);
+  return NS_OK;
+}
+
 nsresult xpc::CreateSandboxObject(JSContext* cx, MutableHandleValue vp,
                                   nsISupports* prinOrSop,
                                   SandboxOptions& options) {
   // Create the sandbox global object
   nsCOMPtr<nsIPrincipal> principal = do_QueryInterface(prinOrSop);
   if (!principal) {
     nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(prinOrSop);
     if (sop) {
@@ -1763,16 +1836,18 @@ nsresult nsXPCComponents_utils_Sandbox::
       ok = false;
     } else if (isArray) {
       if (options.userContextId != 0) {
         // We don't support passing a userContextId with an array.
         ok = false;
       } else {
         ok = GetExpandedPrincipal(cx, obj, options, getter_AddRefs(expanded));
         prinOrSop = expanded;
+        // If this is an addon content script we need to apply the csp.
+        MOZ_TRY(ApplyAddonContentScriptCSP(prinOrSop));
       }
     } else {
       ok = GetPrincipalOrSOP(cx, obj, getter_AddRefs(prinOrSop));
     }
   } else if (args[0].isNull()) {
     // Null means that we just pass prinOrSop = nullptr, and get an
     // NullPrincipal.
     ok = true;
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -2777,16 +2777,28 @@
 #---------------------------------------------------------------------------
 
 # Private browsing opt-in is only supported on Firefox desktop.
 - name: extensions.allowPrivateBrowsingByDefault
   type: bool
   value: @IS_ANDROID@
   mirror: always
 
+# This pref governs whether we enable content script CSP in extensions.
+- name: extensions.content_script_csp.enabled
+  type: bool
+  value: false
+  mirror: always
+
+# This pref governs whether content script CSP is report-only.
+- name: extensions.content_script_csp.report_only
+  type: bool
+  value: true
+  mirror: always
+
 # This pref governs whether we run webextensions in a separate process (true)
 # or the parent/main process (false)
 - name: extensions.webextensions.remote
   type: bool
   value: false
   mirror: always
 
 #---------------------------------------------------------------------------
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js
@@ -0,0 +1,254 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { TestUtils } = ChromeUtils.import(
+  "resource://testing-common/TestUtils.jsm"
+);
+
+// Enable and turn off report-only so we can validate the results.
+Services.prefs.setBoolPref("extensions.content_script_csp.enabled", true);
+Services.prefs.setBoolPref("extensions.content_script_csp.report_only", false);
+
+const server = createHttpServer({
+  hosts: ["example.com", "csplog.example.net"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`;
+var gCSP = gDefaultCSP;
+const pageContent = `<!DOCTYPE html>
+  <html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <title></title>
+  </head>
+  <body>
+  <img id="testimg">
+  </body>
+  </html>`;
+
+server.registerPathHandler("/plain.html", (request, response) => {
+  response.setStatusLine(request.httpVersion, 200, "OK");
+  response.setHeader("Content-Type", "text/html");
+  if (gCSP) {
+    info(`Content-Security-Policy: ${gCSP}`);
+    response.setHeader("Content-Security-Policy", gCSP);
+  }
+  response.write(pageContent);
+});
+
+const BASE_URL = `http://example.com`;
+const pageURL = `${BASE_URL}/plain.html`;
+
+const CSP_REPORT_PATH = "/csp-report.sjs";
+const CSP_REPORT = `report-uri http://csplog.example.net${CSP_REPORT_PATH};`;
+
+function readUTF8InputStream(stream) {
+  let buffer = NetUtil.readInputStream(stream, stream.available());
+  return new TextDecoder().decode(buffer);
+}
+
+server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
+  response.setStatusLine(request.httpVersion, 204, "No Content");
+  let data = readUTF8InputStream(request.bodyInputStream);
+  info(`CSP-REPORT: ${data}`);
+  Services.obs.notifyObservers(null, "extension-test-csp-report", data);
+});
+
+async function promiseCSPReport(test) {
+  let res = await TestUtils.topicObserved("extension-test-csp-report", test);
+  info(`CSP-REPORT-RECEIVED: ${res[1]}`);
+  return JSON.parse(res[1]);
+}
+
+// Test functions loaded into extension content script.
+function testImage(data) {
+  return new Promise(resolve => {
+    let img = window.document.getElementById("testimg");
+    img.onload = () => resolve(true);
+    img.onerror = () => {
+      browser.test.log(`img error: ${img.src}`);
+      resolve(false);
+    };
+    img.src = data.image_url;
+  });
+}
+
+function testFetch(data) {
+  let f = data.content ? content.fetch : fetch;
+  return f(data.url)
+    .then(() => true)
+    .catch(e => {
+      browser.test.assertEq(
+        e.message,
+        "NetworkError when attempting to fetch resource.",
+        "expected fetch failure"
+      );
+      return false;
+    });
+}
+
+// If the violation source is the extension the securitypolicyviolation event is not fired.
+// If the page is the source, the event is fired and both the content script or page scripts
+// will receive the event.  If we're expecting a moz-extension report  we'll  fail in the
+// event listener if we receive a report.  Otherwise we want to resolve in the listener to
+// ensure we've received the event for the test.
+function contentScript(report) {
+  return new Promise(resolve => {
+    if (!report || report["document-uri"] === "moz-extension") {
+      resolve();
+    }
+    // eslint-disable-next-line mozilla/balanced-listeners
+    document.addEventListener("securitypolicyviolation", e => {
+      browser.test.assertTrue(
+        e.documentURI !== "moz-extension",
+        `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
+      );
+      resolve();
+    });
+  });
+}
+
+const gDefaultContentScriptCSP =
+  "default-src 'self' 'report-sample'; object-src 'self'; script-src 'self';";
+
+let TESTS = [
+  // Image Tests
+  {
+    description:
+      "Image from content script using default extension csp. Image is allowed.",
+    pageCSP: `${gDefaultCSP} img-src 'none';`,
+    script: testImage,
+    data: { image_url: `${BASE_URL}/data/file_image_good.png` },
+    expect: true,
+  },
+  {
+    description:
+      "Image from content script using extension csp. Image is not allowed.",
+    pageCSP: `${gDefaultCSP} img-src 'self';`,
+    scriptCSP: `${gDefaultContentScriptCSP} img-src 'none';`,
+    script: testImage,
+    data: { image_url: `${BASE_URL}/data/file_image_good.png` },
+    expect: false,
+    report: {
+      "blocked-uri": `${BASE_URL}/data/file_image_good.png`,
+      "document-uri": "moz-extension",
+      "violated-directive": "img-src",
+    },
+  },
+  // Fetch Tests
+  {
+    description: "Fetch url in content script uses default extension csp.",
+    pageCSP: `${gDefaultCSP} connect-src 'none';`,
+    script: testFetch,
+    data: { url: `${BASE_URL}/data/file_image_good.png` },
+    expect: true,
+  },
+  {
+    description: "Fetch url in content script uses extension csp.",
+    pageCSP: `${gDefaultCSP} connect-src 'none';`,
+    script: testFetch,
+    scriptCSP: `${gDefaultContentScriptCSP} connect-src 'none';`,
+    data: { url: `${BASE_URL}/data/file_image_good.png` },
+    expect: false,
+    report: {
+      "blocked-uri": `${BASE_URL}/data/file_image_good.png`,
+      "document-uri": "moz-extension",
+      "violated-directive": "connect-src",
+    },
+  },
+  {
+    description: "Fetch full url from content script uses page csp.",
+    pageCSP: `${gDefaultCSP} connect-src 'none';`,
+    script: testFetch,
+    data: {
+      content: true,
+      url: `${BASE_URL}/data/file_image_good.png`,
+    },
+    expect: false,
+    report: {
+      "blocked-uri": `${BASE_URL}/data/file_image_good.png`,
+      "document-uri": `${BASE_URL}/plain.html`,
+      "violated-directive": "connect-src",
+    },
+  },
+  {
+    description: "Fetch url from content script uses page csp.",
+    pageCSP: `${gDefaultCSP} connect-src *;`,
+    script: testFetch,
+    scriptCSP: `${gDefaultContentScriptCSP} connect-src 'none' 'report-sample';`,
+    data: {
+      content: true,
+      url: `${BASE_URL}/data/file_image_good.png`,
+    },
+    expect: true,
+  },
+
+  // TODO Bug 1587939: Eval tests.
+];
+
+async function runCSPTest(test) {
+  // Set the CSP for the page loaded into the tab.
+  gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`;
+  info(`running test using CSP: ${gCSP}`);
+  let data = {
+    manifest: {
+      content_scripts: [
+        {
+          matches: ["http://*/plain.html"],
+          run_at: "document_idle",
+          js: ["content_script.js"],
+        },
+      ],
+      permissions: ["<all_urls>"],
+    },
+
+    files: {
+      "content_script.js": `
+      (${contentScript})(${JSON.stringify(test.report)}).then(() => {
+        browser.test.sendMessage("violationEvent");
+      });
+      (${test.script})(${JSON.stringify(test.data)}).then(result => {
+        browser.test.sendMessage("result", result);
+      });
+      `,
+    },
+  };
+  if (test.scriptCSP) {
+    info(`ADDON-CSP: ${test.scriptCSP}`);
+    data.manifest.content_security_policy = {
+      content_scripts: `${test.scriptCSP} ${CSP_REPORT}`,
+    };
+  }
+  let extension = ExtensionTestUtils.loadExtension(data);
+  await extension.startup();
+
+  info(`TESTING: ${test.description}`);
+  let reportPromise = test.report && promiseCSPReport();
+  let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+  await extension.awaitMessage("violationEvent");
+  let result = await extension.awaitMessage("result");
+  equal(result, test.expect, test.description);
+  if (test.report) {
+    let report = await reportPromise;
+    for (let key of Object.keys(test.report)) {
+      equal(
+        report["csp-report"][key],
+        test.report[key],
+        `csp-report ${key} matches`
+      );
+    }
+  }
+
+  await extension.unload();
+  await contentPage.close();
+  clearCache();
+}
+
+add_task(async function test_contentscript_csp() {
+  for (let test of TESTS) {
+    await runCSPTest(test);
+  }
+});
--- a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
@@ -24,22 +24,27 @@ Services.prefs.setIntPref(
   "security.csp.reporting.script-sample.max-length",
   4096
 );
 
 // ExtensionContent.jsm needs to know when it's running from xpcshell,
 // to use the right timeout for content scripts executed at document_idle.
 ExtensionTestUtils.mockAppInfo();
 
-const server = createHttpServer();
+const server = createHttpServer({
+  hosts: ["example.com", "csplog.example.net"],
+});
+
 server.registerDirectory("/data/", do_get_file("data"));
 
 var gContentSecurityPolicy = null;
 
+const BASE_URL = `http://example.com`;
 const CSP_REPORT_PATH = "/csp-report.sjs";
+const CSP_REPORT_URL = `http://csplog.example.net/csp-report.sjs`;
 
 /**
  * Registers a static HTML document with the given content at the given
  * path in our test HTTP server.
  *
  * @param {string} path
  * @param {string} content
  */
@@ -49,18 +54,16 @@ function registerStaticPage(path, conten
     response.setHeader("Content-Type", "text/html");
     if (gContentSecurityPolicy) {
       response.setHeader("Content-Security-Policy", gContentSecurityPolicy);
     }
     response.write(content);
   });
 }
 
-const BASE_URL = `http://localhost:${server.identity.primaryPort}`;
-
 /**
  * A set of tags which are automatically closed in HTML documents, and
  * do not require an explicit closing tag.
  */
 const AUTOCLOSE_TAGS = new Set(["img", "input", "link", "source"]);
 
 /**
  * An object describing the elements to create for a specific test.
@@ -860,32 +863,41 @@ function computeBaseURLs(tests, expected
  * @param {string} message.sources.*.origin
  *        The origin of the CSS, one of "page", "contentScript", or "extension".
  * @param {string} message.sources.*.css
  *        The CSS source text.
  * @param {boolean} [cspEnabled = false]
  *        If true, a strict CSP is enabled for this page, and inline page
  *        sources should be blocked. URLs present in these sources will not be
  *        expected to generate a CSP report, the inline sources themselves will.
+ * @param {boolean} [contentCspEnabled = false]
  * @returns {RequestedURLs}
  */
-function computeExpectedForbiddenURLs({ urls, sources }, cspEnabled = false) {
+function computeExpectedForbiddenURLs(
+  { urls, sources },
+  cspEnabled = false,
+  contentCspEnabled = false
+) {
   let expectedURLs = new Set();
   let forbiddenURLs = new Set();
   let blockedURLs = new Set();
   let blockedSources = new Set();
 
   for (let { href, origin, inline } of urls) {
     let { baseURL } = getOriginBase(href);
     if (cspEnabled && origin === "page") {
       if (inline) {
         forbiddenURLs.add(baseURL);
       } else {
         blockedURLs.add(baseURL);
       }
+    } else if (contentCspEnabled && origin === "contentScript") {
+      if (inline) {
+        forbiddenURLs.add(baseURL);
+      }
     } else {
       expectedURLs.add(baseURL);
     }
   }
 
   if (cspEnabled) {
     for (let { origin, css } of sources) {
       if (origin === "page") {
@@ -998,35 +1010,36 @@ function awaitCSP(urlsPromise) {
 
         if (expectedURLs.has(baseURL)) {
           ok(false, `Got unexpected CSP report for allowed URL ${origURL}`);
         }
 
         if (blockedURLs.has(baseURL)) {
           blockedURLs.delete(baseURL);
 
-          info(`Got CSP report for forbidden URL ${origURL}`);
+          ok(true, `Got CSP report for forbidden URL ${origURL}`);
         }
       }
 
       let source = report["script-sample"];
       if (source) {
         if (blockedSources.has(source)) {
           blockedSources.delete(source);
 
-          info(
+          ok(
+            true,
             `Got CSP report for forbidden inline source ${JSON.stringify(
               source
             )}`
           );
         }
       }
 
       if (!blockedURLs.size && !blockedSources.size) {
-        info("Got all expected CSP reports");
+        ok(true, "Got all expected CSP reports");
         resolve();
       }
     }
 
     urlsPromise.then(urls => {
       blockedURLs = new Set(urls.blockedURLs);
       blockedSources = new Set(urls.blockedSources);
       ({ expectedURLs } = urls);
@@ -1134,16 +1147,26 @@ const PAGE_SOURCES = {
 // Sources which load with the extension context.
 const EXTENSION_SOURCES = {
   contentScript: {},
   "contentScript-attr-after-inject": { liveSrc: true },
   "contentScript-content-inject-after-attr": {},
   "contentScript-prop": {},
   "contentScript-prop-after-inject": {},
 };
+// When our default content script CSP is applied, only
+// liveSrc: true are loading.  IOW, the "script" test above
+// will fail.
+const EXTENSION_SOURCES_CONTENT_CSP = {
+  contentScript: { liveSrc: true },
+  "contentScript-attr-after-inject": { liveSrc: true },
+  "contentScript-content-inject-after-attr": { liveSrc: true },
+  "contentScript-prop": { liveSrc: true },
+  "contentScript-prop-after-inject": { liveSrc: true },
+};
 // All sources.
 const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES);
 
 registerStaticPage(
   "/page.html",
   `<!DOCTYPE html>
   <html lang="en">
   <head>
@@ -1156,28 +1179,39 @@ registerStaticPage(
   <body>
     ${TESTS.map(test =>
       toHTML(test, { source: "pageHTML", origin: "page" })
     ).join("\n  ")}
   </body>
   </html>`
 );
 
+function catchViolation() {
+  // eslint-disable-next-line mozilla/balanced-listeners
+  document.addEventListener("securitypolicyviolation", e => {
+    browser.test.assertTrue(
+      e.documentURI !== "moz-extension",
+      `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
+    );
+  });
+}
+
 const EXTENSION_DATA = {
   manifest: {
     content_scripts: [
       {
         matches: ["http://*/page.html"],
         run_at: "document_start",
-        js: ["content_script.js"],
+        js: ["violation.js", "content_script.js"],
       },
     ],
   },
 
   files: {
+    "violation.js": catchViolation,
     "content_script.js": getInjectionScript(TESTS, {
       source: "contentScript",
       origin: "contentScript",
     }),
   },
 };
 
 const pageURL = `${BASE_URL}/page.html`;
@@ -1267,8 +1301,65 @@ add_task(async function test_contentscri
 
   let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
 
   await finished;
 
   await extension.unload();
   await contentPage.close();
 });
+
+/**
+ * Tests that the correct CSP is applied to loads of inline content
+ * depending on whether the load was initiated by an extension or the
+ * content page.
+ */
+add_task(async function test_extension_contentscript_csp() {
+  Services.prefs.setBoolPref("extensions.content_script_csp.enabled", true);
+  Services.prefs.setBoolPref(
+    "extensions.content_script_csp.report_only",
+    false
+  );
+
+  // Add reporting to base and default CSP as this cannot be done via manifest.
+  let baseCSP = Services.prefs.getStringPref(
+    "extensions.webextensions.base-content-security-policy"
+  );
+  Services.prefs.setStringPref(
+    "extensions.webextensions.base-content-security-policy",
+    `${baseCSP} report-uri ${CSP_REPORT_URL};`
+  );
+  Services.prefs.setStringPref(
+    "extensions.webextensions.default-content-security-policy",
+    `script-src 'self' 'report-sample'; object-src 'self' 'report-sample'; report-uri ${CSP_REPORT_URL};`
+  );
+
+  // TODO bug 1408193: We currently don't get the full set of CSP reports when
+  // running in network scheduling chaos mode. It's not entirely clear why.
+  let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16);
+  let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02);
+
+  gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`;
+
+  let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+  await extension.startup();
+
+  let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+    return mergeSources(
+      computeExpectedForbiddenURLs(msg, true, true),
+      computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES)
+    );
+  });
+
+  let origins = getOrigins(extension.extension);
+
+  let finished = Promise.all([
+    awaitLoads(urlsPromise, origins),
+    checkCSPReports && awaitCSP(urlsPromise),
+  ]);
+
+  let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+  await finished;
+
+  await extension.unload();
+  await contentPage.close();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -31,16 +31,17 @@ run-sequentially = node server exception
 [test_ext_content_security_policy.js]
 skip-if = (os == "win" && debug) #Bug 1485567
 [test_ext_contentscript_api_injection.js]
 [test_ext_contentscript_async_loading.js]
 skip-if = os == 'android' && debug # The generated script takes too long to load on Android debug
 [test_ext_contentscript_context.js]
 [test_ext_contentscript_context_isolation.js]
 [test_ext_contentscript_create_iframe.js]
+[test_ext_contentscript_csp.js]
 [test_ext_contentscript_css.js]
 [test_ext_contentscript_exporthelpers.js]
 [test_ext_contentscript_in_background.js]
 [test_ext_contentscript_restrictSchemes.js]
 [test_ext_contentscript_teardown.js]
 [test_ext_contextual_identities.js]
 skip-if = appname == "thunderbird" || os == "android" # Containers are not exposed to android.
 [test_ext_debugging_utils.js]