Bug 1415352: Part 3b - Add tests for triggering principal and CSP subjection of style attributes. r?bz draft
authorKris Maglione <maglione.k@gmail.com>
Mon, 06 Nov 2017 19:01:39 -0800
changeset 694619 267e46d5e2376eddd8356958c077ca963df88aec
parent 694618 7d46d8b96f038c64a1792edf8a48b9d2c35c1e39
child 694620 ec70c957a05c7d267ebd0ee1196c595548a25938
push id88175
push usermaglione.k@gmail.com
push dateTue, 07 Nov 2017 23:59:46 +0000
reviewersbz
bugs1415352
milestone58.0a1
Bug 1415352: Part 3b - Add tests for triggering principal and CSP subjection of style attributes. r?bz MozReview-Commit-ID: 9vNQEihXh9G
toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
--- a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
@@ -15,16 +15,20 @@ const env = Cc["@mozilla.org/process/env
 
 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);
 
+// Increase the length of the code samples included in CSP reports so that we
+// can correctly validate them.
+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();
 server.registerDirectory("/data/", do_get_file("data"));
 
 var gContentSecurityPolicy = null;
@@ -240,29 +244,148 @@ function toHTML(test, opts) {
       html.push(`</${tagName}>`);
     }
     return html.join("");
   }
   return rec(getElementData(test, opts));
 }
 
 /**
+ * Injects various permutations of inline CSS into a content page, from both
+ * extension content script and content page contexts, and sends a "css-sources"
+ * message to the test harness describing the injected content for verification.
+ */
+function testInlineCSS() {
+  let urls = [];
+  let sources = [];
+
+  /**
+   * Constructs the URL of an image to be loaded by the given origin, and
+   * returns a CSS url() expression for it.
+   *
+   * The `name` parameter is an arbitrary name which should describe how the URL
+   * is loaded. The `opts` object may contain arbitrary properties which
+   * describe the load. Currently, only `inline` is recognized, and indicates
+   * that the URL is being used in an inline stylesheet which may be blocked by
+   * CSP.
+   *
+   * The URL and its parameters are recorded, and sent to the parent process for
+   * verification.
+   *
+   * @param {string} origin
+   * @param {string} name
+   * @param {object} [opts]
+   * @returns {string}
+   */
+  let i = 0;
+  let url = (origin, name, opts = {}) => {
+    let source = `${origin}-${name}`;
+
+    let {href} = new URL(`css-${i++}.png?origin=${encodeURIComponent(origin)}&source=${encodeURIComponent(source)}`,
+                         location.href);
+
+    urls.push(Object.assign({}, opts, {href, origin, source}));
+    return `url(${href})`;
+  };
+
+  /**
+   * Registers the given inline CSS source as being loaded by the given origin,
+   * and returns that CSS text.
+   *
+   * @param {string} origin
+   * @param {string} css
+   * @returns {string}
+   */
+  let source = (origin, css) => {
+    sources.push({origin, css});
+    return css;
+  };
+
+  /**
+   * Saves the given function to be run after a short delay, just before sending
+   * the list of loaded sources to the parent process.
+   */
+  let laters = [];
+  let later = (fn) => {
+    laters.push(fn);
+  };
+
+  // Note: When accessing an element through `wrappedJSObject`, the operations
+  // occur in the content page context, using the content subject principal.
+  // When accessing it through X-ray wrappers, they happen in the content script
+  // context, using its subject principal.
+
+  {
+    let li = document.createElement("li");
+    li.setAttribute("style", source("extension", `background: ${url("extension", "li.style-first")}`));
+    li.style.wrappedJSObject.listStyleImage = url("page", "li.style.listStyleImage-second");
+    document.body.appendChild(li);
+  }
+
+  {
+    let li = document.createElement("li");
+    li.wrappedJSObject.setAttribute("style", source("page", `background: ${url("page", "li.style-first", {inline: true})}`));
+    li.style.listStyleImage = url("extension", "li.style.listStyleImage-second");
+    document.body.appendChild(li);
+  }
+
+  {
+    let li = document.createElement("li");
+    document.body.appendChild(li);
+    li.setAttribute("style", source("extension", `background: ${url("extension", "li.style-first")}`));
+    later(() => li.wrappedJSObject.setAttribute("style", source("page", `background: ${url("page", "li.style-second", {inline: true})}`)));
+  }
+
+  {
+    let li = document.createElement("li");
+    document.body.appendChild(li);
+    li.wrappedJSObject.setAttribute("style", source("page", `background: ${url("page", "li.style-first", {inline: true})}`));
+    later(() => li.setAttribute("style", source("extension", `background: ${url("extension", "li.style-second")}`)));
+  }
+
+  {
+    let li = document.createElement("li");
+    document.body.appendChild(li);
+    li.style.cssText = source("extension", `background: ${url("extension", "li.style.cssText-first")}`);
+
+    // TODO: This inline style should be blocked, since our style-src does not
+    // include 'unsafe-eval', but that is currently unimplemented.
+    later(() => { li.style.wrappedJSObject.cssText = `background: ${url("page", "li.style.cssText-second")}`; });
+  }
+
+  setTimeout(() => {
+    for (let fn of laters) {
+      fn();
+    }
+    browser.test.sendMessage("css-sources", {urls, sources});
+  });
+}
+
+/**
  * A function which will be stringified, and run both as a page script
  * and an extension content script, to test element injection under
  * various configurations.
  *
  * @param {Array<ElementTestCase>} tests
  *        A list of test objects, as understood by {@see getElementData}.
  * @param {ElementTestOptions} baseOpts
  *        A base options object, as understood by {@see getElementData},
  *        which represents the default values for injections under this
  *        context.
  */
 function injectElements(tests, baseOpts) {
   window.addEventListener("load", () => {
+    if (typeof browser === "object") {
+      try {
+        testInlineCSS();
+      } catch (e) {
+        browser.test.fail(`Error: ${e} :: ${e.stack}`);
+      }
+    }
+
     // Basic smoke test to check that SVG images do not try to create a document
     // with an expanded principal, which would cause a crash.
     let img = document.createElement("img");
     img.src = "data:image/svg+xml,%3Csvg%2F%3E";
     document.body.appendChild(img);
 
     let rand = Math.random();
 
@@ -391,40 +514,76 @@ function injectElements(tests, baseOpts)
  *        which represents the default values for injections under this
  *        context.
  * @returns {string}
  */
 function getInjectionScript(tests, opts) {
   return `
     ${getElementData}
     ${createElement}
+    ${testInlineCSS}
     (${injectElements})(${JSON.stringify(tests)},
                         ${JSON.stringify(opts)});
   `;
 }
 
 /**
+ * Extracts the "origin" query parameter from the given URL, and returns it,
+ * along with the URL sans origin parameter.
+ *
+ * @param {string} origURL
+ * @returns {object}
+ *        An object with `origin` and `baseURL` properties, containing the value
+ *        or the URL's "origin" query parameter and the URL with that parameter
+ *        removed, respectively.
+ */
+function getOriginBase(origURL) {
+  let url = new URL(origURL);
+  let origin = url.searchParams.get("origin");
+  url.searchParams.delete("origin");
+
+  return {origin, baseURL: url.href};
+}
+
+/**
+ * An object containing sets of base URLs and CSS sources which are present in
+ * the test page, sorted based on how they should be treated by CSP.
+ *
+ * @typedef {object} ElementTestCase
+ * @property {Set<string>} expectedURLs
+ *        A set of URLs which should be successfully requested by the content
+ *        page.
+ * @property {Set<string>} forbiddenURLs
+ *        A set of URLs which are present in the content page, but should never
+ *        generate requests.
+ * @property {Set<string>} blockedURLs
+ *        A set of URLs which are present in the content page, and should be
+ *        blocked by CSP, and reported in a CSP report.
+ * @property {Set<string>} blockedSources
+ *        A set of inline CSS sources which should be blocked by CSP, and
+ *        reported in a CSP report.
+ */
+
+/**
  * 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>} 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.
+ * @returns {RequestedURLs}
  */
 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])) {
@@ -436,115 +595,219 @@ function computeBaseURLs(tests, expected
   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};
+
+  return {expectedURLs, forbiddenURLs, blockedURLs: forbiddenURLs};
+}
+
+/**
+ * Generates a set of expected and forbidden URLs and sources based on the CSS
+ * injected by our content script.
+ *
+ * @param {object} message
+ *        The "css-sources" message sent by the content script, containing lists
+ *        of CSS sources injected into the page.
+ * @param {Array<object>} message.urls
+ *        A list of URLs present in styles injected by the content script.
+ * @param {string} message.urls.*.origin
+ *        The origin of the URL, either "page" or "extension".
+ * @param {string} message.urls.*.href
+ *        The URL string.
+ * @param {boolean} message.urls.*.inline
+ *        If true, the URL is present in an inline stylesheet, which may be
+ *        blocked by CSP prior to parsing, depending on its origin.
+ * @param {Array<object>} message.sources
+ *        A list of inline CSS sources injected by the content script.
+ * @param {string} message.sources.*.origin
+ *        The origin of the CSS, either "page" 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.
+ * @returns {RequestedURLs}
+ */
+function computeExpectedForbiddenURLs({urls, sources}, cspEnabled = 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 {
+      expectedURLs.add(baseURL);
+    }
+  }
+
+  if (cspEnabled) {
+    for (let {origin, css} of sources) {
+      if (origin === "page") {
+        blockedSources.add(css);
+      }
+    }
+  }
+
+  return {expectedURLs, forbiddenURLs, blockedURLs, blockedSources};
 }
 
 /**
  * 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 {Promise<object>} urlsPromise
+ *        A promise which resolves to 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({expectedURLs, forbiddenURLs}, origins) {
-  expectedURLs = new Set(expectedURLs);
-
+function awaitLoads(urlsPromise, origins) {
   return new Promise(resolve => {
-    let observer = (channel, topic, data) => {
-      channel.QueryInterface(Ci.nsIChannel);
+    let expectedURLs, forbiddenURLs;
+    let queuedChannels = [];
 
+    let observer;
+
+    function checkChannel(channel) {
       let origURL = channel.URI.spec;
-      let url = new URL(origURL);
-      let origin = url.searchParams.get("origin");
-      url.searchParams.delete("origin");
+      let {baseURL, origin} = getOriginBase(origURL);
 
-
-      if (forbiddenURLs.has(url.href)) {
+      if (forbiddenURLs.has(baseURL)) {
         ok(false, `Got unexpected request for forbidden URL ${origURL}`);
       }
 
-      if (expectedURLs.has(url.href)) {
-        expectedURLs.delete(url.href);
+      if (expectedURLs.has(baseURL)) {
+        expectedURLs.delete(baseURL);
 
         equal(channel.loadInfo.triggeringPrincipal.origin,
               origins[origin],
               `Got expected origin for URL ${origURL}`);
 
         if (!expectedURLs.size) {
           Services.obs.removeObserver(observer, "http-on-modify-request");
           do_print("Got all expected requests");
           resolve();
         }
       }
+    }
+
+    urlsPromise.then(urls => {
+      expectedURLs = new Set(urls.expectedURLs);
+      forbiddenURLs = new Set([...urls.forbiddenURLs,
+                               ...urls.blockedURLs]);
+
+      for (let channel of queuedChannels.splice(0)) {
+        checkChannel(channel.QueryInterface(Ci.nsIChannel));
+      }
+    });
+
+    observer = (channel, topic, data) => {
+      if (expectedURLs) {
+        checkChannel(channel.QueryInterface(Ci.nsIChannel));
+      } else {
+        queuedChannels.push(channel);
+      }
     };
     Services.obs.addObserver(observer, "http-on-modify-request");
   });
 }
 
 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}.
+ * @param {Promise<object>} urlsPromise
+ *        A promise which resolves to 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);
-
+function awaitCSP(urlsPromise) {
   return new Promise(resolve => {
-    server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
-      response.setStatusLine(request.httpVersion, 204, "No Content");
+    let expectedURLs, blockedURLs, blockedSources;
+    let queuedRequests = [];
 
+    function checkRequest(request) {
       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 (origURL !== "self") {
+        let {baseURL} = getOriginBase(origURL);
+
+        if (expectedURLs.has(baseURL)) {
+          ok(false, `Got unexpected CSP report for allowed URL ${origURL}`);
+        }
+
+        if (blockedURLs.has(baseURL)) {
+          blockedURLs.delete(baseURL);
 
-      if (expectedURLs.has(url.href)) {
-        ok(false, `Got unexpected CSP report for allowed URL ${origURL}`);
+          do_print(`Got CSP report for forbidden URL ${origURL}`);
+        }
+      }
+
+      let source = report["script-sample"];
+      if (source) {
+        if (blockedSources.has(source)) {
+          blockedSources.delete(source);
+
+          do_print(`Got CSP report for forbidden inline source ${JSON.stringify(source)}`);
+        }
       }
 
-      if (forbiddenURLs.has(url.href)) {
-        forbiddenURLs.delete(url.href);
+      if (!blockedURLs.size && !blockedSources.size) {
+        do_print("Got all expected CSP reports");
+        resolve();
+      }
+    }
 
-        do_print(`Got CSP report for forbidden URL ${origURL}`);
+    urlsPromise.then(urls => {
+      blockedURLs = new Set(urls.blockedURLs);
+      blockedSources = new Set(urls.blockedSources);
+      ({expectedURLs} = urls);
 
-        if (!forbiddenURLs.size) {
-          do_print("Got all expected CSP reports");
-          resolve();
-        }
+      for (let request of queuedRequests.splice(0)) {
+        checkRequest(request);
+      }
+    });
+
+    server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
+      response.setStatusLine(request.httpVersion, 204, "No Content");
+
+      if (expectedURLs) {
+        checkRequest(request);
+      } else {
+        queuedRequests.push(request);
       }
     });
   });
 }
 
 /**
  * A list of tests to run in each context, as understood by
  * {@see getElementData}.
@@ -665,29 +928,46 @@ const EXTENSION_DATA = {
   files: {
     "content_script.js": getInjectionScript(TESTS, {source: "contentScript", origin: "extension"}),
   },
 };
 
 const pageURL = `${BASE_URL}/page.html`;
 const pageURI = Services.io.newURI(pageURL);
 
+// Merges the sets of expected URL and source data returned by separate
+// computedExpectedForbiddenURLs and computedBaseURLs calls.
+function mergeSources(a, b) {
+  return {
+    expectedURLs: new Set([...a.expectedURLs, ...b.expectedURLs]),
+    forbiddenURLs: new Set([...a.forbiddenURLs, ...b.forbiddenURLs]),
+    blockedURLs: new Set([...a.blockedURLs, ...b.blockedURLs]),
+    blockedSources: a.blockedSources || b.blockedSources,
+  };
+}
+
 /**
  * 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 urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+    return mergeSources(
+      computeExpectedForbiddenURLs(msg),
+      computeBaseURLs(TESTS, SOURCES));
+  });
+
   let origins = {
     page: Services.scriptSecurityManager.createCodebasePrincipal(pageURI, {}).origin,
     extension: Cu.getObjectPrincipal(Cu.Sandbox([extension.extension.principal, pageURL])).origin,
   };
-  let finished = awaitLoads(computeBaseURLs(TESTS, SOURCES), origins);
+  let finished = awaitLoads(urlsPromise, origins);
 
   let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
 
   await finished;
 
   await extension.unload();
   await contentPage.close();
 
@@ -706,25 +986,30 @@ add_task(async function test_contentscri
   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 urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+    return mergeSources(
+      computeExpectedForbiddenURLs(msg, true),
+      computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES));
+  });
+
   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),
+    awaitLoads(urlsPromise, origins),
+    checkCSPReports && awaitCSP(urlsPromise),
   ]);
 
   let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
 
   await finished;
 
   await extension.unload();
   await contentPage.close();