Bug 1785821 - Only allow manifest_version 3 extensions to merge CSP in the short run. r=mixedpuppy

This patch includes:

- changes to WebRequest.jsm to always default to only merge the CSP headers returned by MV3 extensions

- changes to the test_ext_webRequest_mergecsp.js xpcshell test to cover the behavior expected
  with MV3 extensions and combinations of both MV2 and MV3 extensions changing CSP headers
  for the same intercepted web request.

For MV3 extensions we would prefer a more explicit and predictable way for the
extensions to be allowed to replace the CSP header, instead of keeping the same
unpredictable and implicit one that we currently support for MV2 extensions.

Differential Revision: https://phabricator.services.mozilla.com/D154983
This commit is contained in:
Luca Greco 2022-08-25 19:13:50 +00:00
Родитель fcf4ce2092
Коммит 5549205044
2 изменённых файлов: 421 добавлений и 76 удалений

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

@ -2,6 +2,16 @@
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
AddonTestUtils.init(this);
AddonTestUtils.createAppInfo(
"xpcshell@tests.mozilla.org",
"XPCShell",
"1",
"43"
);
const server = createHttpServer({
hosts: ["example.net", "example.com"],
});
@ -32,7 +42,7 @@ let extensionData = {
},
background() {
let csp_value = undefined;
browser.test.onMessage.addListener(function(msg, expectedCount) {
browser.test.onMessage.addListener(function(msg) {
csp_value = msg;
browser.test.sendMessage("csp-set");
});
@ -63,20 +73,31 @@ let extensionData = {
/**
* Test a combination of Content Security Policies against first/third party images/scripts.
* @param {string} site_csp The CSP to be sent by the site, or null.
* @param {string} ext1_csp The CSP to be sent by the first extension,
* @param {Object} opts
* @param {string} opts.site_csp The CSP to be sent by the site, or null.
* @param {string} opts.ext1_csp The CSP to be sent by the first extension,
* "" to remove the header, or null to not modify it.
* @param {string} ext2_csp The CSP to be sent by the first extension,
* @param {string} opts.ext2_csp The CSP to be sent by the first extension,
* "" to remove the header, or null to not modify it.
* @param {Object} expect Object containing information which resources are expected to be loaded.
* @param {Object} expect.img1_loaded image from a first party origin.
* @param {Object} expect.img3_loaded image from a third party origin.
* @param {Object} expect.script1_loaded script from a first party origin.
* @param {Object} expect.script3_loaded script from a third party origin.
* @param {Object} opts.expect Object containing information which resources are expected to be loaded.
* @param {Object} opts.expect.img1_loaded image from a first party origin.
* @param {Object} opts.expect.img3_loaded image from a third party origin.
* @param {Object} opts.expect.script1_loaded script from a first party origin.
* @param {Object} opts.expect.script3_loaded script from a third party origin.
* @param {Object} [opts.expect.cspJSON] expected final document CSP (in JSON format, See dom/webidl/CSPDictionaries.webidl).
* @param {Object} [opts.extData1] first test extension definition data (defaults to extensionData).
* @param {Object} [opts.extData2] second test extension definition data (defaults to extensionData).
*/
async function test_csp(site_csp, ext1_csp, ext2_csp, expect) {
let extension1 = await ExtensionTestUtils.loadExtension(extensionData);
let extension2 = await ExtensionTestUtils.loadExtension(extensionData);
async function test_csp({
site_csp,
ext1_csp,
ext2_csp,
expect,
ext1_data = extensionData,
ext2_data = extensionData,
}) {
let extension1 = await ExtensionTestUtils.loadExtension(ext1_data);
let extension2 = await ExtensionTestUtils.loadExtension(ext2_data);
await extension1.startup();
await extension2.startup();
extension1.sendMessage(ext1_csp);
@ -91,12 +112,14 @@ async function test_csp(site_csp, ext1_csp, ext2_csp, expect) {
let results = await contentPage.spawn(null, async () => {
let img1 = this.content.document.getElementById("img1");
let img3 = this.content.document.getElementById("img3");
let cspJSON = JSON.parse(this.content.document.cspJSON);
return {
img1_loaded: img1.complete && img1.naturalWidth > 0,
img3_loaded: img3.complete && img3.naturalWidth > 0,
// Note: "good" and "bad" are just placeholders; they don't mean anything.
script1_loaded: !!this.content.document.getElementById("good"),
script3_loaded: !!this.content.document.getElementById("bad"),
cspJSON,
};
});
@ -109,7 +132,11 @@ async function test_csp(site_csp, ext1_csp, ext2_csp, expect) {
false: "blocked",
};
info(`test_csp: From "${site_csp}" to "${ext1_csp}" to "${ext2_csp}"`);
info(
`test_csp: From "${site_csp}" to ${JSON.stringify(
ext1_csp
)} to ${JSON.stringify(ext2_csp)}`
);
equal(
expect.img1_loaded,
@ -131,84 +158,382 @@ async function test_csp(site_csp, ext1_csp, ext2_csp, expect) {
results.script3_loaded,
`expected third party script to be ${action[expect.script3_loaded]}`
);
if (expect.cspJSON) {
Assert.deepEqual(
expect.cspJSON,
results.cspJSON["csp-policies"],
`Got the expected final CSP set on the content document`
);
}
}
add_setup(async () => {
await AddonTestUtils.promiseStartupManager();
});
// Test that merging csp header on both mv2 and mv3 extensions
// (and combination of both).
add_task(async function test_webRequest_mergecsp() {
await test_csp("default-src *", "script-src 'none'", null, {
img1_loaded: true,
img3_loaded: true,
script1_loaded: false,
script3_loaded: false,
});
await test_csp(null, "script-src 'none'", null, {
img1_loaded: true,
img3_loaded: true,
script1_loaded: false,
script3_loaded: false,
});
await test_csp("default-src *", "script-src 'none'", "img-src 'none'", {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
});
await test_csp(null, "script-src 'none'", "img-src 'none'", {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
});
await test_csp(
"default-src *",
"img-src example.com",
"img-src example.org",
const testCases = [
{
site_csp: "default-src *",
ext1_csp: "script-src 'none'",
ext2_csp: null,
expect: {
img1_loaded: true,
img3_loaded: true,
script1_loaded: false,
script3_loaded: false,
},
},
{
site_csp: null,
ext1_csp: "script-src 'none'",
ext2_csp: null,
expect: {
img1_loaded: true,
img3_loaded: true,
script1_loaded: false,
script3_loaded: false,
},
},
{
site_csp: "default-src *",
ext1_csp: "script-src 'none'",
ext2_csp: "img-src 'none'",
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
},
},
{
site_csp: null,
ext1_csp: "script-src 'none'",
ext2_csp: "img-src 'none'",
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
},
},
{
site_csp: "default-src *",
ext1_csp: "img-src example.com",
ext2_csp: "img-src example.org",
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: true,
script3_loaded: true,
},
},
];
const extMV2Data = { ...extensionData };
const extMV3Data = {
...extensionData,
useAddonManager: "temporary",
manifest: {
...extensionData.manifest,
manifest_version: 3,
permissions: ["webRequest", "webRequestBlocking"],
host_permissions: ["*://example.net/*"],
granted_host_permissions: true,
},
};
info("Run all test cases on ext1 MV2 and ext2 MV2");
for (const testCase of testCases) {
await test_csp({
...testCase,
ext1_data: extMV2Data,
ext2_data: extMV2Data,
});
}
info("Run all test cases on ext1 MV3 and ext2 MV3");
for (const testCase of testCases) {
await test_csp({
...testCase,
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
}
info("Run all test cases on ext1 MV3 and ext2 MV2");
for (const testCase of testCases) {
await test_csp({
...testCase,
ext1_data: extMV3Data,
ext2_data: extMV2Data,
});
}
info("Run all test cases on ext1 MV2 and ext2 MV3");
for (const testCase of testCases) {
await test_csp({
...testCase,
ext1_data: extMV2Data,
ext2_data: extMV3Data,
});
}
});
add_task(async function test_remove_and_replace_csp_mv2() {
// CSP removed, CSP added.
await test_csp({
site_csp: "img-src 'self'",
ext1_csp: "",
ext2_csp: "img-src example.com",
expect: {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
},
});
// CSP removed, CSP added.
await test_csp({
site_csp: "default-src 'none'",
ext1_csp: "",
ext2_csp: "img-src example.com",
expect: {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
},
});
// CSP replaced - regression test for bug 1635781.
await test_csp({
site_csp: "default-src 'none'",
ext1_csp: "img-src example.com",
ext2_csp: null,
expect: {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
},
});
// CSP unchanged, CSP replaced - regression test for bug 1635781.
await test_csp({
site_csp: "default-src 'none'",
ext1_csp: null,
ext2_csp: "img-src example.com",
expect: {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
},
});
// CSP replaced, CSP removed.
await test_csp({
site_csp: "default-src 'none'",
ext1_csp: "img-src example.com",
ext2_csp: "",
expect: {
img1_loaded: true,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
},
});
});
// Test that fully replace the website csp header from an mv3 extension
// isn't allowed and it is considered a no-op.
add_task(async function test_remove_and_replace_csp_mv3() {
const extMV2Data = { ...extensionData };
const extMV3Data = {
...extensionData,
useAddonManager: "temporary",
manifest: {
...extensionData.manifest,
manifest_version: 3,
permissions: ["webRequest", "webRequestBlocking"],
host_permissions: ["*://example.net/*"],
granted_host_permissions: true,
},
};
await test_csp({
// site: CSP strict on images, lax on default and script src.
site_csp: "img-src 'self'",
// ext1: MV3 extension which return an empty CSP header (which is a no-op).
ext1_csp: "",
// ext2: MV3 extension which return a CSP header (which is expected to be merged).
ext2_csp: "img-src example.com",
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: true,
script3_loaded: true,
}
);
});
add_task(async function test_remove_and_replace_csp() {
// CSP removed, CSP added.
await test_csp("img-src 'self'", "", "img-src example.com", {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
cspJSON: [
{ "img-src": ["'self'"], "report-only": false },
{ "img-src": ["http://example.com"], "report-only": false },
],
},
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
// CSP removed, CSP added.
await test_csp("default-src 'none'", "", "img-src example.com", {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
await test_csp({
// site: CSP strict on default-src.
site_csp: "default-src 'none'",
// ext1: MV3 extension which return an empty CSP header (which is a no-op).
ext1_csp: "",
// ext2: MV3 extension which return a CSP header (which is expected to be merged).
ext2_csp: "img-src example.com",
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
cspJSON: [
{ "default-src": ["'none'"], "report-only": false },
{ "img-src": ["http://example.com"], "report-only": false },
],
},
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
// CSP replaced - regression test for bug 1635781.
await test_csp("default-src 'none'", "img-src example.com", null, {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
await test_csp({
// site: CSP strict on default-src.
site_csp: "default-src 'none'",
// ext1: MV3 extension which return a CSP header (which is expected to be merged and to
// not be able to make it less strict).
ext1_csp: "img-src example.com",
// ext2: MV3 extension which leaves the header unmodified.
ext2_csp: null,
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
cspJSON: [
{ "default-src": ["'none'"], "report-only": false },
{ "img-src": ["http://example.com"], "report-only": false },
],
},
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
// CSP unchanged, CSP replaced - regression test for bug 1635781.
await test_csp("default-src 'none'", null, "img-src example.com", {
img1_loaded: false,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
await test_csp({
// site: CSP strict on default-src.
site_csp: "default-src 'none'",
// ext1: MV3 extension which merges additional directive into the site csp (and can't make
// it less strict).
ext1_csp: "img-src example.com",
// ext2: MV3 extension which merges an empty CSP header (which is a no-op, unlike with MV2).
ext2_csp: "",
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
cspJSON: [
{ "default-src": ["'none'"], "report-only": false },
{ "img-src": ["http://example.com"], "report-only": false },
],
},
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
// CSP replaced, CSP removed.
await test_csp("default-src 'none'", "img-src example.com", "", {
img1_loaded: true,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
await test_csp({
// site: lax CSP (which is expected to be made stricted by the ext1 extension).
site_csp: "default-src *",
// ext1: MV3 extension which wants to set a stricter CSP (expected to work fine with the MV3 extension)
ext1_csp: "default-src 'none'",
// ext2: MV3 extension which leaves it unchanged.
ext2_csp: null,
expect: {
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
cspJSON: [
{ "default-src": ["*"], "report-only": false },
{ "default-src": ["'none'"], "report-only": false },
],
},
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
await test_csp({
// site: CSP strict on default-src.
site_csp: "default-src 'none'",
// ext1: MV3 extension and tries to replace the strict site csp with this lax one
// (but as an MV3 extension that is going to be merged to the site csp and the
// resulting site CSP is expected to stay strict).
ext1_csp: "default-src *",
// ext2: MV3 extension which leaves it unchanged.
ext2_csp: null,
expect: {
// strict site csp merged with the lax one from ext1 stays strict.
img1_loaded: false,
img3_loaded: false,
script1_loaded: false,
script3_loaded: false,
cspJSON: [
{ "default-src": ["'none'"], "report-only": false },
{ "default-src": ["*"], "report-only": false },
],
},
ext1_data: extMV3Data,
ext2_data: extMV3Data,
});
await test_csp({
// site: CSP strict on default-src.
site_csp: "default-src 'none'",
// ext1: MV3 extension which return an empty CSP (expected to be a no-op for an MV3 extension).
ext1_csp: "",
// ext2: MV2 exension which wants to replace the site csp with a lax one (and still be allowed to
// because the empty one from the MV3 extension is expected to be a no-op).
ext2_csp: "default-src *",
expect: {
img1_loaded: true,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
cspJSON: [{ "default-src": ["*"], "report-only": false }],
},
ext1_data: extMV3Data,
ext2_data: extMV2Data,
});
await test_csp({
// site: CSP strict on default-src.
site_csp: "default-src 'none'",
// ext1: MV3 extension which return an empty CSP (which is expected to be a no-op).
ext1_csp: "",
// ext2: MV2 extension which also returns an empty CSP (which for an MV2 extension is expected
// to clear the CSP).
ext2_csp: "",
expect: {
img1_loaded: true,
img3_loaded: true,
script1_loaded: true,
script3_loaded: true,
// Expect the resulting final document CSP to be empty (due to the MV2 extension clearing it).
cspJSON: [],
},
ext1_data: extMV3Data,
ext2_data: extMV2Data,
});
});

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

@ -248,6 +248,26 @@ class ResponseHeaderChanger extends HeaderChanger {
if (value) {
merge = merge || this.didModifyCSP;
}
// For manifest_version 3 extension, we are currently only allowing to
// merge additional CSP strings to the existing ones, which will be initially
// stricter than currently allowed to manifest_version 2 extensions, then
// following up with either a new permission and/or some more changes to the
// APIs (and possibly making the behavior more deterministic than it is for
// manifest_version 2 at the moment).
if (opts.policy.manifestVersion > 2) {
if (value) {
// If the given CSP header value is non empty, then it should be
// merged to the existing one.
merge = true;
} else {
// If the given CSP header value is empty (which would be clearing the
// CSP header), it should be considered a no-op and this.didModifyCSP
// shouldn't be changed to true.
return;
}
}
this.didModifyCSP = true;
}
try {