--- 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();
});