Bug 1581611 Part 2: apply content script csp r=robwu,ckerschb

Manifest V3 functionality.  This applies CSP on the webextension content scripts using either a default csp or an
extension provided csp.  It will remain pref'd off but is available for developers to test against, as well as for future
validation of chrome compatibility.

Differential Revision: https://phabricator.services.mozilla.com/D48107

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Shane Caraveo 2019-11-01 06:03:13 +00:00
Родитель f800952de9
Коммит 8aa898bdb2
5 изменённых файлов: 441 добавлений и 8 удалений

Просмотреть файл

@ -64,9 +64,13 @@
#include "mozilla/dom/XMLHttpRequest.h"
#include "mozilla/dom/XMLSerializerBinding.h"
#include "mozilla/dom/FormDataBinding.h"
#include "mozilla/dom/nsCSPContext.h"
#include "mozilla/BasePrincipal.h"
#include "mozilla/DeferredFinalize.h"
#include "mozilla/ExtensionPolicyService.h"
#include "mozilla/NullPrincipal.h"
#include "mozilla/ResultExtensions.h"
#include "mozilla/StaticPrefs_extensions.h"
using namespace mozilla;
using namespace JS;
@ -1018,6 +1022,75 @@ bool xpc::GlobalProperties::DefineInSandbox(JSContext* cx,
return Define(cx, obj);
}
/**
* If enabled, apply the extension base CSP, then apply the
* content script CSP which will either be a default or one
* provided by the extension in its manifest.
*/
nsresult ApplyAddonContentScriptCSP(nsISupports* prinOrSop) {
if (!StaticPrefs::extensions_content_script_csp_enabled()) {
return NS_OK;
}
nsCOMPtr<nsIPrincipal> principal = do_QueryInterface(prinOrSop);
if (!principal) {
return NS_OK;
}
auto* basePrin = BasePrincipal::Cast(principal);
// We only get an addonPolicy if the principal is an
// expanded principal with an extension principal in it.
auto* addonPolicy = basePrin->ContentScriptAddonPolicy();
if (!addonPolicy) {
return NS_OK;
}
nsString url;
MOZ_TRY_VAR(url, addonPolicy->GetURL(NS_LITERAL_STRING("")));
nsCOMPtr<nsIURI> selfURI;
MOZ_TRY(NS_NewURI(getter_AddRefs(selfURI), url));
nsAutoString baseCSP;
MOZ_ALWAYS_SUCCEEDS(
ExtensionPolicyService::GetSingleton().GetBaseCSP(baseCSP));
// If we got here, we're definitly an expanded principal.
auto expanded = basePrin->As<ExpandedPrincipal>();
nsCOMPtr<nsIContentSecurityPolicy> csp;
#ifdef MOZ_DEBUG
// Bug 1548468: Move CSP off ExpandedPrincipal
expanded->GetCsp(getter_AddRefs(csp));
if (csp) {
uint32_t count = 0;
csp->GetPolicyCount(&count);
if (count > 0) {
// Ensure that the policy was not already added.
nsAutoString parsedPolicyStr;
for (uint32_t i = 0; i < count; i++) {
csp->GetPolicyString(i, parsedPolicyStr);
MOZ_ASSERT(!parsedPolicyStr.Equals(baseCSP));
}
}
}
#endif
csp = new nsCSPContext();
MOZ_TRY(
csp->SetRequestContextWithPrincipal(expanded, selfURI, EmptyString(), 0));
bool reportOnly = StaticPrefs::extensions_content_script_csp_report_only();
MOZ_TRY(csp->AppendPolicy(baseCSP, reportOnly, false));
// Set default or extension provided csp.
const nsAString& contentScriptCSP = addonPolicy->ContentScriptCSP();
MOZ_TRY(csp->AppendPolicy(contentScriptCSP, reportOnly, false));
expanded->SetCsp(csp);
return NS_OK;
}
nsresult xpc::CreateSandboxObject(JSContext* cx, MutableHandleValue vp,
nsISupports* prinOrSop,
SandboxOptions& options) {
@ -1768,6 +1841,8 @@ nsresult nsXPCComponents_utils_Sandbox::CallOrConstruct(
} else {
ok = GetExpandedPrincipal(cx, obj, options, getter_AddRefs(expanded));
prinOrSop = expanded;
// If this is an addon content script we need to apply the csp.
MOZ_TRY(ApplyAddonContentScriptCSP(prinOrSop));
}
} else {
ok = GetPrincipalOrSOP(cx, obj, getter_AddRefs(prinOrSop));

Просмотреть файл

@ -2782,6 +2782,18 @@
value: @IS_ANDROID@
mirror: always
# This pref governs whether we enable content script CSP in extensions.
- name: extensions.content_script_csp.enabled
type: bool
value: false
mirror: always
# This pref governs whether content script CSP is report-only.
- name: extensions.content_script_csp.report_only
type: bool
value: true
mirror: always
# This pref governs whether we run webextensions in a separate process (true)
# or the parent/main process (false)
- name: extensions.webextensions.remote

Просмотреть файл

@ -0,0 +1,254 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
const { TestUtils } = ChromeUtils.import(
"resource://testing-common/TestUtils.jsm"
);
// Enable and turn off report-only so we can validate the results.
Services.prefs.setBoolPref("extensions.content_script_csp.enabled", true);
Services.prefs.setBoolPref("extensions.content_script_csp.report_only", false);
const server = createHttpServer({
hosts: ["example.com", "csplog.example.net"],
});
server.registerDirectory("/data/", do_get_file("data"));
var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`;
var gCSP = gDefaultCSP;
const pageContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<img id="testimg">
</body>
</html>`;
server.registerPathHandler("/plain.html", (request, response) => {
response.setStatusLine(request.httpVersion, 200, "OK");
response.setHeader("Content-Type", "text/html");
if (gCSP) {
info(`Content-Security-Policy: ${gCSP}`);
response.setHeader("Content-Security-Policy", gCSP);
}
response.write(pageContent);
});
const BASE_URL = `http://example.com`;
const pageURL = `${BASE_URL}/plain.html`;
const CSP_REPORT_PATH = "/csp-report.sjs";
const CSP_REPORT = `report-uri http://csplog.example.net${CSP_REPORT_PATH};`;
function readUTF8InputStream(stream) {
let buffer = NetUtil.readInputStream(stream, stream.available());
return new TextDecoder().decode(buffer);
}
server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
response.setStatusLine(request.httpVersion, 204, "No Content");
let data = readUTF8InputStream(request.bodyInputStream);
info(`CSP-REPORT: ${data}`);
Services.obs.notifyObservers(null, "extension-test-csp-report", data);
});
async function promiseCSPReport(test) {
let res = await TestUtils.topicObserved("extension-test-csp-report", test);
info(`CSP-REPORT-RECEIVED: ${res[1]}`);
return JSON.parse(res[1]);
}
// Test functions loaded into extension content script.
function testImage(data) {
return new Promise(resolve => {
let img = window.document.getElementById("testimg");
img.onload = () => resolve(true);
img.onerror = () => {
browser.test.log(`img error: ${img.src}`);
resolve(false);
};
img.src = data.image_url;
});
}
function testFetch(data) {
let f = data.content ? content.fetch : fetch;
return f(data.url)
.then(() => true)
.catch(e => {
browser.test.assertEq(
e.message,
"NetworkError when attempting to fetch resource.",
"expected fetch failure"
);
return false;
});
}
// If the violation source is the extension the securitypolicyviolation event is not fired.
// If the page is the source, the event is fired and both the content script or page scripts
// will receive the event. If we're expecting a moz-extension report we'll fail in the
// event listener if we receive a report. Otherwise we want to resolve in the listener to
// ensure we've received the event for the test.
function contentScript(report) {
return new Promise(resolve => {
if (!report || report["document-uri"] === "moz-extension") {
resolve();
}
// eslint-disable-next-line mozilla/balanced-listeners
document.addEventListener("securitypolicyviolation", e => {
browser.test.assertTrue(
e.documentURI !== "moz-extension",
`securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
);
resolve();
});
});
}
const gDefaultContentScriptCSP =
"default-src 'self' 'report-sample'; object-src 'self'; script-src 'self';";
let TESTS = [
// Image Tests
{
description:
"Image from content script using default extension csp. Image is allowed.",
pageCSP: `${gDefaultCSP} img-src 'none';`,
script: testImage,
data: { image_url: `${BASE_URL}/data/file_image_good.png` },
expect: true,
},
{
description:
"Image from content script using extension csp. Image is not allowed.",
pageCSP: `${gDefaultCSP} img-src 'self';`,
scriptCSP: `${gDefaultContentScriptCSP} img-src 'none';`,
script: testImage,
data: { image_url: `${BASE_URL}/data/file_image_good.png` },
expect: false,
report: {
"blocked-uri": `${BASE_URL}/data/file_image_good.png`,
"document-uri": "moz-extension",
"violated-directive": "img-src",
},
},
// Fetch Tests
{
description: "Fetch url in content script uses default extension csp.",
pageCSP: `${gDefaultCSP} connect-src 'none';`,
script: testFetch,
data: { url: `${BASE_URL}/data/file_image_good.png` },
expect: true,
},
{
description: "Fetch url in content script uses extension csp.",
pageCSP: `${gDefaultCSP} connect-src 'none';`,
script: testFetch,
scriptCSP: `${gDefaultContentScriptCSP} connect-src 'none';`,
data: { url: `${BASE_URL}/data/file_image_good.png` },
expect: false,
report: {
"blocked-uri": `${BASE_URL}/data/file_image_good.png`,
"document-uri": "moz-extension",
"violated-directive": "connect-src",
},
},
{
description: "Fetch full url from content script uses page csp.",
pageCSP: `${gDefaultCSP} connect-src 'none';`,
script: testFetch,
data: {
content: true,
url: `${BASE_URL}/data/file_image_good.png`,
},
expect: false,
report: {
"blocked-uri": `${BASE_URL}/data/file_image_good.png`,
"document-uri": `${BASE_URL}/plain.html`,
"violated-directive": "connect-src",
},
},
{
description: "Fetch url from content script uses page csp.",
pageCSP: `${gDefaultCSP} connect-src *;`,
script: testFetch,
scriptCSP: `${gDefaultContentScriptCSP} connect-src 'none' 'report-sample';`,
data: {
content: true,
url: `${BASE_URL}/data/file_image_good.png`,
},
expect: true,
},
// TODO Bug 1587939: Eval tests.
];
async function runCSPTest(test) {
// Set the CSP for the page loaded into the tab.
gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`;
info(`running test using CSP: ${gCSP}`);
let data = {
manifest: {
content_scripts: [
{
matches: ["http://*/plain.html"],
run_at: "document_idle",
js: ["content_script.js"],
},
],
permissions: ["<all_urls>"],
},
files: {
"content_script.js": `
(${contentScript})(${JSON.stringify(test.report)}).then(() => {
browser.test.sendMessage("violationEvent");
});
(${test.script})(${JSON.stringify(test.data)}).then(result => {
browser.test.sendMessage("result", result);
});
`,
},
};
if (test.scriptCSP) {
info(`ADDON-CSP: ${test.scriptCSP}`);
data.manifest.content_security_policy = {
content_scripts: `${test.scriptCSP} ${CSP_REPORT}`,
};
}
let extension = ExtensionTestUtils.loadExtension(data);
await extension.startup();
info(`TESTING: ${test.description}`);
let reportPromise = test.report && promiseCSPReport();
let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
await extension.awaitMessage("violationEvent");
let result = await extension.awaitMessage("result");
equal(result, test.expect, test.description);
if (test.report) {
let report = await reportPromise;
for (let key of Object.keys(test.report)) {
equal(
report["csp-report"][key],
test.report[key],
`csp-report ${key} matches`
);
}
}
await extension.unload();
await contentPage.close();
clearCache();
}
add_task(async function test_contentscript_csp() {
for (let test of TESTS) {
await runCSPTest(test);
}
});

Просмотреть файл

@ -29,12 +29,17 @@ Services.prefs.setIntPref(
// to use the right timeout for content scripts executed at document_idle.
ExtensionTestUtils.mockAppInfo();
const server = createHttpServer();
const server = createHttpServer({
hosts: ["example.com", "csplog.example.net"],
});
server.registerDirectory("/data/", do_get_file("data"));
var gContentSecurityPolicy = null;
const BASE_URL = `http://example.com`;
const CSP_REPORT_PATH = "/csp-report.sjs";
const CSP_REPORT_URL = `http://csplog.example.net/csp-report.sjs`;
/**
* Registers a static HTML document with the given content at the given
@ -54,8 +59,6 @@ function registerStaticPage(path, content) {
});
}
const BASE_URL = `http://localhost:${server.identity.primaryPort}`;
/**
* A set of tags which are automatically closed in HTML documents, and
* do not require an explicit closing tag.
@ -865,9 +868,14 @@ function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) {
* 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.
* @param {boolean} [contentCspEnabled = false]
* @returns {RequestedURLs}
*/
function computeExpectedForbiddenURLs({ urls, sources }, cspEnabled = false) {
function computeExpectedForbiddenURLs(
{ urls, sources },
cspEnabled = false,
contentCspEnabled = false
) {
let expectedURLs = new Set();
let forbiddenURLs = new Set();
let blockedURLs = new Set();
@ -881,6 +889,10 @@ function computeExpectedForbiddenURLs({ urls, sources }, cspEnabled = false) {
} else {
blockedURLs.add(baseURL);
}
} else if (contentCspEnabled && origin === "contentScript") {
if (inline) {
forbiddenURLs.add(baseURL);
}
} else {
expectedURLs.add(baseURL);
}
@ -1003,7 +1015,7 @@ function awaitCSP(urlsPromise) {
if (blockedURLs.has(baseURL)) {
blockedURLs.delete(baseURL);
info(`Got CSP report for forbidden URL ${origURL}`);
ok(true, `Got CSP report for forbidden URL ${origURL}`);
}
}
@ -1012,7 +1024,8 @@ function awaitCSP(urlsPromise) {
if (blockedSources.has(source)) {
blockedSources.delete(source);
info(
ok(
true,
`Got CSP report for forbidden inline source ${JSON.stringify(
source
)}`
@ -1021,7 +1034,7 @@ function awaitCSP(urlsPromise) {
}
if (!blockedURLs.size && !blockedSources.size) {
info("Got all expected CSP reports");
ok(true, "Got all expected CSP reports");
resolve();
}
}
@ -1139,6 +1152,16 @@ const EXTENSION_SOURCES = {
"contentScript-prop": {},
"contentScript-prop-after-inject": {},
};
// When our default content script CSP is applied, only
// liveSrc: true are loading. IOW, the "script" test above
// will fail.
const EXTENSION_SOURCES_CONTENT_CSP = {
contentScript: { liveSrc: true },
"contentScript-attr-after-inject": { liveSrc: true },
"contentScript-content-inject-after-attr": { liveSrc: true },
"contentScript-prop": { liveSrc: true },
"contentScript-prop-after-inject": { liveSrc: true },
};
// All sources.
const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES);
@ -1161,18 +1184,29 @@ registerStaticPage(
</html>`
);
function catchViolation() {
// eslint-disable-next-line mozilla/balanced-listeners
document.addEventListener("securitypolicyviolation", e => {
browser.test.assertTrue(
e.documentURI !== "moz-extension",
`securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
);
});
}
const EXTENSION_DATA = {
manifest: {
content_scripts: [
{
matches: ["http://*/page.html"],
run_at: "document_start",
js: ["content_script.js"],
js: ["violation.js", "content_script.js"],
},
],
},
files: {
"violation.js": catchViolation,
"content_script.js": getInjectionScript(TESTS, {
source: "contentScript",
origin: "contentScript",
@ -1272,3 +1306,60 @@ add_task(async function test_contentscript_csp() {
await extension.unload();
await contentPage.close();
});
/**
* 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_extension_contentscript_csp() {
Services.prefs.setBoolPref("extensions.content_script_csp.enabled", true);
Services.prefs.setBoolPref(
"extensions.content_script_csp.report_only",
false
);
// Add reporting to base and default CSP as this cannot be done via manifest.
let baseCSP = Services.prefs.getStringPref(
"extensions.webextensions.base-content-security-policy"
);
Services.prefs.setStringPref(
"extensions.webextensions.base-content-security-policy",
`${baseCSP} report-uri ${CSP_REPORT_URL};`
);
Services.prefs.setStringPref(
"extensions.webextensions.default-content-security-policy",
`script-src 'self' 'report-sample'; object-src 'self' 'report-sample'; report-uri ${CSP_REPORT_URL};`
);
// TODO bug 1408193: 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' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; 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, true),
computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES)
);
});
let origins = getOrigins(extension.extension);
let finished = Promise.all([
awaitLoads(urlsPromise, origins),
checkCSPReports && awaitCSP(urlsPromise),
]);
let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
await finished;
await extension.unload();
await contentPage.close();
});

Просмотреть файл

@ -36,6 +36,7 @@ skip-if = os == 'android' && debug # The generated script takes too long to load
[test_ext_contentscript_context.js]
[test_ext_contentscript_context_isolation.js]
[test_ext_contentscript_create_iframe.js]
[test_ext_contentscript_csp.js]
[test_ext_contentscript_css.js]
[test_ext_contentscript_exporthelpers.js]
[test_ext_contentscript_in_background.js]