Bug 1407056: Part 3 - Test that CSP overrides apply correctly based on triggering principals. r=bz
authorKris Maglione <maglione.k@gmail.com>
Thu, 12 Oct 2017 15:44:32 -0700
changeset 386036 d480c295d4eb093b1890056cc724e12bda7ac3ca
parent 386035 41e5a4b54c93e40e92e1e697efa936e90a4c5a41
child 386037 ef7ff96cd4d0fd465a7b94925358558764867c03
push id32673
push userarchaeopteryx@coole-files.de
push dateFri, 13 Oct 2017 09:13:17 +0000
treeherdermozilla-central@196dadb2fe50 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbz
bugs1407056
milestone58.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 1407056: Part 3 - Test that CSP overrides apply correctly based on triggering principals. r=bz MozReview-Commit-ID: EbGsI3keeG6
toolkit/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
--- a/toolkit/components/extensions/test/xpcshell/head.js
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -1,13 +1,13 @@
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
-/* exported createHttpServer, promiseConsoleOutput, cleanupDir, testEnv */
+/* exported createHttpServer, promiseConsoleOutput, cleanupDir, clearCache, testEnv */
 
 Components.utils.import("resource://gre/modules/AppConstants.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 Components.utils.import("resource://gre/modules/Timer.jsm");
 Components.utils.import("resource://testing-common/AddonTestUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
@@ -59,16 +59,30 @@ function createHttpServer(port = -1) {
 
   return server;
 }
 
 if (AppConstants.platform === "android") {
   Services.io.offline = true;
 }
 
+/**
+ * Clears the HTTP and content image caches.
+ */
+function clearCache() {
+  let cache = Cc["@mozilla.org/netwerk/cache-storage-service;1"]
+      .getService(Ci.nsICacheStorageService);
+  cache.clear();
+
+  let imageCache = Cc["@mozilla.org/image/tools;1"]
+      .getService(Ci.imgITools)
+      .getImgCacheForDocument(null);
+  imageCache.clearCache(false);
+}
+
 var promiseConsoleOutput = async function(task) {
   const DONE = `=== console listener ${Math.random()} done ===`;
 
   let listener;
   let messages = [];
   let awaitListener = new Promise(resolve => {
     listener = msg => {
       if (msg == DONE) {
--- a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
@@ -1,44 +1,55 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 /**
  * Tests that various types of inline content elements initiate requests
- * with the triggering pringipal of the caller that requested the load.
+ * with the triggering pringipal of the caller that requested the load,
+ * and that the correct security policies are applied to the resulting
+ * loads.
  */
 
 const {escaped} = Cu.import("resource://testing-common/AddonTestUtils.jsm", {});
 
+const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
+
 Cu.importGlobalProperties(["URL"]);
 
 // Make sure media pre-loading is enabled on Android so that our <audio> and
 // <video> elements trigger the expected requests.
 Services.prefs.setBoolPref("media.autoplay.enabled", true);
 Services.prefs.setIntPref("media.preload.default", 3);
 
 // 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();
 server.registerDirectory("/data/", do_get_file("data"));
 
+var gContentSecurityPolicy = null;
+
+const CSP_REPORT_PATH = "/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
  */
 function registerStaticPage(path, content) {
   server.registerPathHandler(path, (request, response) => {
     response.setStatusLine(request.httpVersion, 200, "OK");
     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
@@ -371,202 +382,335 @@ function getInjectionScript(tests, opts)
     ${getElementData}
     ${createElement}
     (${injectElements})(${JSON.stringify(tests)},
                         ${JSON.stringify(opts)});
   `;
 }
 
 /**
- * Awaits the content loads for each of the given tests, with each of
- * the given sources, and checks that their origin strings are as
- * expected.
+ * Computes a list of expected and forbidden base URLs for the given
+ * sets of tests and sources. The base URL is the complete request URL
+ * with the `origin` query parameter removed.
  *
  * @param {Array<ElementTestCase>} tests
  *        A list of tests, as understood by {@see getElementData}.
- * @param {Object<string, object>} sources
+ * @param {Object<string, object>} expectedSources
  *        A set of sources for which each of the above tests is expected
  *        to generate one request, if each of the properties in the
  *        value object matches the value of the same property in the
  *        test object.
+ * @param {Object<string, object>} [forbiddenSources = {}]
+ *        A set of sources for which requests should never be sent. Any
+ *        matching requests from these sources will cause the test to
+ *        fail.
+ * @returns {object}
+ *        An object with `expectedURLs` and `forbiddenURLs` property,
+ *        each containing a Set of URL strings.
+ */
+function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) {
+  let expectedURLs = new Set();
+  let forbiddenURLs = new Set();
+
+  function* iterSources(test, sources) {
+    for (let [source, attrs] of Object.entries(sources)) {
+      if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) {
+        yield `${BASE_URL}/${test.src}?source=${source}`;
+      }
+    }
+  }
+
+  for (let test of tests) {
+    for (let urlPrefix of iterSources(test, expectedSources)) {
+      expectedURLs.add(urlPrefix);
+    }
+    for (let urlPrefix of iterSources(test, forbiddenSources)) {
+      forbiddenURLs.add(urlPrefix);
+    }
+  }
+  return {expectedURLs, forbiddenURLs};
+}
+
+/**
+ * Awaits the content loads for each of the given expected base URLs,
+ * and checks that their origin strings are as expected. Triggers a test
+ * failure if any of the given forbidden URLs is requested.
+ *
+ * @param {object} urls
+ *        An object containing expected and forbidden URL sets, as
+ *        returned by {@see computeBaseURLs}.
  * @param {object<string, string>} origins
  *        A mapping of origin parameters as they appear in URL query
  *        strings to the origin strings returned by corresponding
  *        principals. These values are used to test requests against
  *        their expected origins.
  * @returns {Promise}
  *        A promise which resolves when all requests have been
  *        processed.
  */
-function awaitLoads(tests, sources, origins) {
-  let expectedURLs = new Set();
-
-  for (let test of tests) {
-    for (let [source, attrs] of Object.entries(sources)) {
-      if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) {
-        let urlPrefix = `${BASE_URL}/${test.src}?source=${source}`;
-        expectedURLs.add(urlPrefix);
-      }
-    }
-  }
+function awaitLoads({expectedURLs, forbiddenURLs}, origins) {
+  expectedURLs = new Set(expectedURLs);
 
   return new Promise(resolve => {
     let observer = (channel, topic, data) => {
       channel.QueryInterface(Ci.nsIChannel);
 
-      let url = new URL(channel.URI.spec);
+      let origURL = channel.URI.spec;
+      let url = new URL(origURL);
       let origin = url.searchParams.get("origin");
       url.searchParams.delete("origin");
 
+
+      if (forbiddenURLs.has(url.href)) {
+        ok(false, `Got unexpected request for forbidden URL ${origURL}`);
+      }
+
       if (expectedURLs.has(url.href)) {
         expectedURLs.delete(url.href);
 
         equal(channel.loadInfo.triggeringPrincipal.origin,
               origins[origin],
-              `Got expected origin for URL ${channel.URI.spec}`);
+              `Got expected origin for URL ${origURL}`);
 
         if (!expectedURLs.size) {
           Services.obs.removeObserver(observer, "http-on-modify-request");
+          do_print("Got all expected requests");
           resolve();
         }
       }
     };
     Services.obs.addObserver(observer, "http-on-modify-request");
   });
 }
 
-add_task(async function test_contentscript_triggeringPrincipals() {
-  /**
-   * A list of tests to run in each context, as understood by
-   * {@see getElementData}.
-   */
-  const TESTS = [
-    {
-      element: ["audio", {}],
-      src: "audio.webm",
-    },
-    {
-      element: ["audio", {}, ["source", {}]],
-      src: "audio-source.webm",
-    },
-    // TODO: <frame> element, which requires a frameset document.
-    {
-      element: ["iframe", {}],
-      src: "iframe.html",
-    },
-    {
-      element: ["img", {}],
-      src: "img.png",
-    },
-    {
-      element: ["img", {}],
-      src: "imgset.png",
-      srcAttr: "srcset",
-    },
-    {
-      element: ["input", {type: "image"}],
-      src: "input.png",
-    },
-    {
-      element: ["link", {rel: "stylesheet"}],
-      src: "link.css",
-      srcAttr: "href",
-    },
-    {
-      element: ["picture", {}, ["source", {}], ["img", {}]],
-      src: "picture.png",
-      srcAttr: "srcset",
-    },
-    {
-      element: ["script", {}],
-      src: "script.js",
-      liveSrc: false,
-    },
-    {
-      element: ["video", {}],
-      src: "video.webm",
-    },
-    {
-      element: ["video", {}, ["source", {}]],
-      src: "video-source.webm",
-    },
-  ];
+function readUTF8InputStream(stream) {
+  let buffer = NetUtil.readInputStream(stream, stream.available());
+  return new TextDecoder().decode(buffer);
+}
+
+/**
+ * Awaits CSP reports for each of the given forbidden base URLs.
+ * Triggers a test failure if any of the given expected URLs triggers a
+ * report.
+ *
+ * @param {object} urls
+ *        An object containing expected and forbidden URL sets, as
+ *        returned by {@see computeBaseURLs}.
+ * @returns {Promise}
+ *        A promise which resolves when all requests have been
+ *        processed.
+ */
+function awaitCSP({expectedURLs, forbiddenURLs}) {
+  forbiddenURLs = new Set(forbiddenURLs);
+
+  return new Promise(resolve => {
+    server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
+      response.setStatusLine(request.httpVersion, 204, "No Content");
+
+      let body = JSON.parse(readUTF8InputStream(request.bodyInputStream));
+      let report = body["csp-report"];
+
+      let origURL = report["blocked-uri"];
+      let url = new URL(origURL);
+      url.searchParams.delete("origin");
+
+      if (expectedURLs.has(url.href)) {
+        ok(false, `Got unexpected CSP report for allowed URL ${origURL}`);
+      }
+
+      if (forbiddenURLs.has(url.href)) {
+        forbiddenURLs.delete(url.href);
+
+        do_print(`Got CSP report for forbidden URL ${origURL}`);
+
+        if (!forbiddenURLs.size) {
+          do_print("Got all expected CSP reports");
+          resolve();
+        }
+      }
+    });
+  });
+}
 
-  /**
-   * A set of sources for which each of the above tests is expected to
-   * generate one request, if each of the properties in the value object
-   * matches the value of the same property in the test object.
-   */
-  const SOURCES = {
-    "contentScript": {},
-    "contentScript-attr-after-inject": {liveSrc: true},
-    "contentScript-content-attr-after-inject": {liveSrc: true},
-    "contentScript-content-change-after-inject": {liveSrc: true},
-    "contentScript-content-inject-after-attr": {},
-    "contentScript-inject-after-content-attr": {},
-    "contentScript-prop": {},
-    "contentScript-prop-after-inject": {},
-    "contentScript-relative-url": {},
-    "pageHTML": {},
-    "pageScript": {},
-    "pageScript-attr-after-inject": {},
-    "pageScript-prop": {},
-    "pageScript-prop-after-inject": {},
-    "pageScript-relative-url": {},
-  };
+/**
+ * A list of tests to run in each context, as understood by
+ * {@see getElementData}.
+ */
+const TESTS = [
+  {
+    element: ["audio", {}],
+    src: "audio.webm",
+  },
+  {
+    element: ["audio", {}, ["source", {}]],
+    src: "audio-source.webm",
+  },
+  // TODO: <frame> element, which requires a frameset document.
+  {
+    element: ["iframe", {}],
+    src: "iframe.html",
+  },
+  {
+    element: ["img", {}],
+    src: "img.png",
+  },
+  {
+    element: ["img", {}],
+    src: "imgset.png",
+    srcAttr: "srcset",
+  },
+  {
+    element: ["input", {type: "image"}],
+    src: "input.png",
+  },
+  {
+    element: ["link", {rel: "stylesheet"}],
+    src: "link.css",
+    srcAttr: "href",
+  },
+  {
+    element: ["picture", {}, ["source", {}], ["img", {}]],
+    src: "picture.png",
+    srcAttr: "srcset",
+  },
+  {
+    element: ["script", {}],
+    src: "script.js",
+    liveSrc: false,
+  },
+  {
+    element: ["video", {}],
+    src: "video.webm",
+  },
+  {
+    element: ["video", {}, ["source", {}]],
+    src: "video-source.webm",
+  },
+];
 
-  for (let test of TESTS) {
-    if (!test.srcAttr) {
-      test.srcAttr = "src";
-    }
-    if (!("liveSrc" in test)) {
-      test.liveSrc = true;
-    }
+for (let test of TESTS) {
+  if (!test.srcAttr) {
+    test.srcAttr = "src";
   }
-
+  if (!("liveSrc" in test)) {
+    test.liveSrc = true;
+  }
+}
 
-  registerStaticPage("/page.html", `<!DOCTYPE html>
-    <html lang="en">
-    <head>
-      <meta charset="UTF-8">
-      <title></title>
-      <script>
-        ${getInjectionScript(TESTS, {source: "pageScript", origin: "page"})}
-      </script>
-    </head>
-    <body>
-      ${TESTS.map(test => toHTML(test, {source: "pageHTML", origin: "page"})).join("\n  ")}
-    </body>
-    </html>`);
-
+/**
+ * A set of sources for which each of the above tests is expected to
+ * generate one request, if each of the properties in the value object
+ * matches the value of the same property in the test object.
+ */
+// Sources which load with the page context.
+const PAGE_SOURCES = {
+  "contentScript-content-attr-after-inject": {liveSrc: true},
+  "contentScript-content-change-after-inject": {liveSrc: true},
+  "contentScript-inject-after-content-attr": {},
+  "contentScript-relative-url": {},
+  "pageHTML": {},
+  "pageScript": {},
+  "pageScript-attr-after-inject": {},
+  "pageScript-prop": {},
+  "pageScript-prop-after-inject": {},
+  "pageScript-relative-url": {},
+};
+// 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": {},
+};
+// All sources.
+const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES);
 
-  let extension = ExtensionTestUtils.loadExtension({
-    manifest: {
-      content_scripts: [{
-        "matches": ["http://*/page.html"],
-        "run_at": "document_start",
-        "js": ["content_script.js"],
-      }],
-    },
+registerStaticPage("/page.html", `<!DOCTYPE html>
+  <html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <title></title>
+    <script nonce="deadbeef">
+      ${getInjectionScript(TESTS, {source: "pageScript", origin: "page"})}
+    </script>
+  </head>
+  <body>
+    ${TESTS.map(test => toHTML(test, {source: "pageHTML", origin: "page"})).join("\n  ")}
+  </body>
+  </html>`);
 
-    files: {
-      "content_script.js": getInjectionScript(TESTS, {source: "contentScript", origin: "extension"}),
-    },
-  });
+const EXTENSION_DATA = {
+  manifest: {
+    content_scripts: [{
+      "matches": ["http://*/page.html"],
+      "run_at": "document_start",
+      "js": ["content_script.js"],
+    }],
+  },
 
-  await extension.startup();
+  files: {
+    "content_script.js": getInjectionScript(TESTS, {source: "contentScript", origin: "extension"}),
+  },
+};
+
+const pageURL = `${BASE_URL}/page.html`;
+const pageURI = Services.io.newURI(pageURL);
 
-  const pageURL = `${BASE_URL}/page.html`;
-  const pageURI = Services.io.newURI(pageURL);
+/**
+ * Tests that various types of inline content elements initiate requests
+ * with the triggering pringipal of the caller that requested the load.
+ */
+add_task(async function test_contentscript_triggeringPrincipals() {
+  let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+  await extension.startup();
 
   let origins = {
     page: Services.scriptSecurityManager.createCodebasePrincipal(pageURI, {}).origin,
     extension: Cu.getObjectPrincipal(Cu.Sandbox([extension.extension.principal, pageURL])).origin,
   };
-  let finished = awaitLoads(TESTS, SOURCES, origins);
+  let finished = awaitLoads(computeBaseURLs(TESTS, SOURCES), origins);
+
+  let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+  await finished;
+
+  await extension.unload();
+  await contentPage.close();
+
+  clearCache();
+});
+
+
+/**
+ * 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_contentscript_csp() {
+  // We currently don't get the full set of CSP reports when running in network
+  // scheduling chaos mode (bug 1408193). It's not entirely clear why.
+  let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16);
+  let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02);
+
+  gContentSecurityPolicy = `default-src 'none'; script-src 'nonce-deadbeef' 'unsafe-eval'; report-uri ${CSP_REPORT_PATH};`;
+
+  let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+  await extension.startup();
+
+  let origins = {
+    page: Services.scriptSecurityManager.createCodebasePrincipal(pageURI, {}).origin,
+    extension: Cu.getObjectPrincipal(Cu.Sandbox([extension.extension.principal, pageURL])).origin,
+  };
+
+  let baseURLs = computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES);
+  let finished = Promise.all([
+    awaitLoads(baseURLs, origins),
+    checkCSPReports && awaitCSP(baseURLs),
+  ]);
 
   let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
 
   await finished;
 
   await extension.unload();
   await contentPage.close();
 });