Bug 1407056: Part 3 - Test that CSP overrides apply correctly based on triggering principals. r?bz draft
authorKris Maglione <maglione.k@gmail.com>
Sat, 07 Oct 2017 14:53:11 -0700
changeset 678076 ddf53adb94bb18f245fba0f3b055813dd5560118
parent 678075 c7fff19733cce0b609487cf0fbe44be11307ba1a
child 678172 6a5ca7444ff921d82293b63db9c29e3b76afcd73
push id83827
push usermaglione.k@gmail.com
push dateTue, 10 Oct 2017 20:40:33 +0000
reviewersbz
bugs1407056
milestone58.0a1
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
@@ -365,202 +376,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. 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();
 });