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