commit a6dee5b53d67439568fc0b968ad76b39094699ae
parent fc3765bf0b6c6dec2645b88f14c74933e42c7e35
Author: James Hay <yellow.desk1472@fastmail.com>
Date: Mon, 24 Nov 2025 02:19:28 +0000
Bug 1864284 - Allow localhost origins in temporary MV3 add-ons. r=robwu
Differential Revision: https://phabricator.services.mozilla.com/D269149
Diffstat:
8 files changed, 301 insertions(+), 31 deletions(-)
diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js
@@ -3164,6 +3164,7 @@ pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/is
// Add-on content security policies.
pref("extensions.webextensions.base-content-security-policy", "script-src 'self' https://* http://localhost:* http://127.0.0.1:* moz-extension: blob: filesystem: 'unsafe-eval' 'wasm-unsafe-eval' 'unsafe-inline';");
pref("extensions.webextensions.base-content-security-policy.v3", "script-src 'self' 'wasm-unsafe-eval';");
+pref("extensions.webextensions.base-content-security-policy.v3-with-localhost", "script-src 'self' 'wasm-unsafe-eval' http://localhost:* http://127.0.0.1:*;");
pref("extensions.webextensions.default-content-security-policy", "script-src 'self' 'wasm-unsafe-eval';");
pref("extensions.webextensions.default-content-security-policy.v3", "script-src 'self'; upgrade-insecure-requests;");
diff --git a/toolkit/components/extensions/Extension.sys.mjs b/toolkit/components/extensions/Extension.sys.mjs
@@ -1717,6 +1717,7 @@ export class ExtensionData {
manifestVersion: this.manifestVersion,
// We introduced this context param in Bug 1831417.
ignoreUnrecognizedProperties: false,
+ temporarilyInstalled: this.temporarilyInstalled,
};
if (this.fluentL10n || this.localeData) {
diff --git a/toolkit/components/extensions/Schemas.sys.mjs b/toolkit/components/extensions/Schemas.sys.mjs
@@ -483,6 +483,10 @@ class Context {
return !!this.params.ignoreUnrecognizedProperties;
}
+ get temporarilyInstalled() {
+ return !!this.params.temporarilyInstalled;
+ }
+
get principal() {
return (
this.params.principal ||
@@ -1270,11 +1274,37 @@ const FORMATS = {
// Manifest V3 extension_pages allows WASM. When sandbox is
// implemented, or any other V3 or later directive, the flags
// logic will need to be updated.
+
let flags =
context.manifestVersion < 3
? Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY
: Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM;
+
let error = lazy.contentPolicyService.validateAddonCSP(string, flags);
+
+ if (
+ error &&
+ context.manifestVersion === 3 &&
+ !lazy.contentPolicyService.validateAddonCSP(
+ string,
+ flags | Ci.nsIAddonContentPolicy.CSP_ALLOW_LOCALHOST
+ )
+ ) {
+ error =
+ `Using localhost in the Content Security Policy is invalid, ` +
+ `and is only permitted during development with temporarily ` +
+ `loaded add-ons`;
+
+ // The error occurred due to the presence of localhost CSP settings, which should be allowed
+ // when an MV3 extension is loaded as a temporary add-on for debugging purposes.
+ if (context.temporarilyInstalled) {
+ context.logWarning(
+ `Warning processing ${context.currentTarget}: ${error}`
+ );
+ return string;
+ }
+ }
+
if (error != null) {
// The CSP validation error is not reported as part of the "choices" error message,
// we log the CSP validation error explicitly here to make it easier for the addon developers
diff --git a/toolkit/components/extensions/WebExtensionPolicy.cpp b/toolkit/components/extensions/WebExtensionPolicy.cpp
@@ -60,6 +60,12 @@ static const char kBackgroundPageHTMLEnd[] =
"extensions.webextensions.base-content-security-policy.v3"
#define DEFAULT_BASE_CSP_V3 "script-src 'self' 'wasm-unsafe-eval';"
+#define BASE_CSP_PREF_V3_WITH_LOCALHOST \
+ "extensions.webextensions.base-content-security-policy.v3-with-localhost"
+#define DEFAULT_BASE_CSP_V3_WITH_LOCALHOST \
+ "script-src 'self' 'wasm-unsafe-eval' http://localhost:* " \
+ "http://127.0.0.1:*;"
+
static inline ExtensionPolicyService& EPS() {
return ExtensionPolicyService::GetSingleton();
}
@@ -202,6 +208,13 @@ WebExtensionPolicyCore::WebExtensionPolicyCore(GlobalObject& aGlobal,
if (NS_FAILED(rv)) {
mBaseCSP = NS_LITERAL_STRING_FROM_CSTRING(DEFAULT_BASE_CSP_V2);
}
+ } else if (mTemporarilyInstalled) {
+ nsresult rv =
+ Preferences::GetString(BASE_CSP_PREF_V3_WITH_LOCALHOST, mBaseCSP);
+ if (NS_FAILED(rv)) {
+ mBaseCSP =
+ NS_LITERAL_STRING_FROM_CSTRING(DEFAULT_BASE_CSP_V3_WITH_LOCALHOST);
+ }
} else {
nsresult rv = Preferences::GetString(BASE_CSP_PREF_V3, mBaseCSP);
if (NS_FAILED(rv)) {
diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js
@@ -15,10 +15,27 @@ const aps = Cc["@mozilla.org/addons/policy-service;1"].getService(
const v2_csp = Preferences.get(
"extensions.webextensions.base-content-security-policy"
);
+
const v3_csp = Preferences.get(
"extensions.webextensions.base-content-security-policy.v3"
);
+const v3_with_localhost_csp = Preferences.get(
+ "extensions.webextensions.base-content-security-policy.v3-with-localhost"
+);
+
+function getExpectedBaseCSP(manifestVersion, temporarilyInstalled) {
+ if (manifestVersion === 2) {
+ return v2_csp;
+ }
+
+ if (temporarilyInstalled) {
+ return v3_with_localhost_csp;
+ }
+
+ return v3_csp;
+}
+
add_task(async function test_invalid_addon_csp() {
await Assert.throws(
() => aps.getBaseCSP("invalid@missing"),
@@ -84,11 +101,32 @@ add_task(async function test_policy_csp() {
},
expectedPolicy: CUSTOM_POLICY,
},
+ {
+ name: "manifest 3 version set (temporary install), no custom policy",
+ policyData: {
+ manifestVersion: 3,
+ temporarilyInstalled: true,
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest 3 version set (temporary install), custom extensionPage policy",
+ policyData: {
+ manifestVersion: 3,
+ temporarilyInstalled: true,
+ extensionPageCSP: `script-src 'self' https://127.0.0.1 https://localhost`,
+ },
+ expectedPolicy: `script-src 'self' https://127.0.0.1 https://localhost`,
+ },
];
let policy = null;
- function setExtensionCSP({ manifestVersion, extensionPageCSP }) {
+ function setExtensionCSP({
+ manifestVersion,
+ extensionPageCSP,
+ temporarilyInstalled,
+ }) {
if (policy) {
policy.active = false;
}
@@ -101,6 +139,7 @@ add_task(async function test_policy_csp() {
allowedOrigins: new MatchPatternSet([]),
localizeCallback() {},
+ temporarilyInstalled,
manifestVersion,
extensionPageCSP,
});
@@ -111,11 +150,13 @@ add_task(async function test_policy_csp() {
for (let test of tests) {
info(test.name);
setExtensionCSP(test.policyData);
- equal(
- aps.getBaseCSP(ADDON_ID),
- test.policyData.manifestVersion == 3 ? v3_csp : v2_csp,
- "baseCSP is correct"
+
+ let expectedBaseCSP = getExpectedBaseCSP(
+ test.policyData.manifestVersion ?? 2,
+ !!test.policyData.temporarilyInstalled
);
+
+ equal(aps.getBaseCSP(ADDON_ID), expectedBaseCSP, "baseCSP is correct");
equal(
aps.getExtensionPageCSP(ADDON_ID),
test.expectedPolicy,
@@ -223,6 +264,28 @@ add_task(async function test_extension_csp() {
expectedPolicy: aps.defaultCSPV3,
},
{
+ name: "manifest_v3 allows localhost when temporarily installed",
+ temporarilyInstalled: true,
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://127.0.0.1 https://localhost`,
+ },
+ },
+ expectedPolicy: `script-src 'self' https://127.0.0.1 https://localhost`,
+ },
+ {
+ name: "manifest_v3 allows 127.0.0.1 when temporarily installed",
+ temporarilyInstalled: true,
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://127.0.0.1`,
+ },
+ },
+ expectedPolicy: `script-src 'self' https://127.0.0.1`,
+ },
+ {
name: "manifest_v3 allows wasm-unsafe-eval",
manifest: {
manifest_version: 3,
@@ -281,19 +344,24 @@ add_task(async function test_extension_csp() {
info(test.name);
let extension = ExtensionTestUtils.loadExtension({
manifest: test.manifest,
+ temporarilyInstalled: !!test.temporarilyInstalled,
});
await extension.startup();
let policy = WebExtensionPolicy.getByID(extension.id);
- equal(
- policy.baseCSP,
- test.manifest.manifest_version == 3 ? v3_csp : v2_csp,
- "baseCSP is correct"
+
+ const expectedBaseCSP = getExpectedBaseCSP(
+ policy.manifestVersion,
+ policy.temporarilyInstalled
);
+
+ equal(policy.baseCSP, expectedBaseCSP, "baseCSP is correct");
+
equal(
policy.extensionPageCSP,
test.expectedPolicy,
"extensionPageCSP is correct."
);
+
await extension.unload();
}
diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js
@@ -273,7 +273,9 @@ add_task(async function test_csp_validator_extension_pages() {
"\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword"
);
- // Localhost is invalid
+ // Localhost is invalid under normal circumstance, but passes through
+ // if validated under CSP_ALLOW_LOCALHOST, which is the validation
+ // setting used for temporarily loaded extensions.
for (let src of [
"http://localhost",
"https://localhost",
@@ -285,6 +287,15 @@ add_task(async function test_csp_validator_extension_pages() {
`script-src 'self' ${src};`,
`\u2018script-src\u2019 directive contains a forbidden ${protocol}: protocol source`
);
+
+ equal(
+ cps.validateAddonCSP(
+ `script-src 'self' ${src}`,
+ Ci.nsIAddonContentPolicy.CSP_ALLOW_LOCALHOST
+ ),
+ null,
+ `Localhost source should be allowed by CSP_ALLOW_LOCALHOST: ${src}`
+ );
}
let directives = ["script-src", "worker-src"];
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js
@@ -16,25 +16,41 @@ server.registerPathHandler("/worker.js", (request, response) => {
response.write("let x = true;");
});
-const baseCSP = [];
-// Keep in sync with extensions.webextensions.base-content-security-policy
-baseCSP[2] = {
- "script-src": [
- "'unsafe-eval'",
- "'wasm-unsafe-eval'",
- "'unsafe-inline'",
- "blob:",
- "filesystem:",
- "http://localhost:*",
- "http://127.0.0.1:*",
- "https://*",
- "moz-extension:",
- "'self'",
- ],
-};
-// Keep in sync with extensions.webextensions.base-content-security-policy.v3
-baseCSP[3] = {
- "script-src": ["'self'", "'wasm-unsafe-eval'"],
+server.registerPathHandler("/local.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/javascript", false);
+ response.write("let y = true;");
+});
+
+const baseCSP = {
+ // Keep in sync with extensions.webextensions.base-content-security-policy
+ v2: {
+ "script-src": [
+ "'unsafe-eval'",
+ "'wasm-unsafe-eval'",
+ "'unsafe-inline'",
+ "blob:",
+ "filesystem:",
+ "http://localhost:*",
+ "http://127.0.0.1:*",
+ "https://*",
+ "moz-extension:",
+ "'self'",
+ ],
+ },
+ // Keep in sync with extensions.webextensions.base-content-security-policy.v3
+ v3: {
+ "script-src": ["'self'", "'wasm-unsafe-eval'"],
+ },
+ // Keep in sync with extensions.webextensions.base-content-security-policy.v3-with-localhost
+ v3_with_localhost: {
+ "script-src": [
+ "'self'",
+ "'wasm-unsafe-eval'",
+ "http://localhost:*",
+ "http://127.0.0.1:*",
+ ],
+ },
};
/**
@@ -43,6 +59,7 @@ baseCSP[3] = {
* @param {boolean} workerEvalAllowed
* @param {boolean} workerImportScriptsAllowed
* @param {boolean} workerWasmAllowed
+ * @param {boolean} localhostAllowed
*/
/**
@@ -55,16 +72,19 @@ baseCSP[3] = {
* @param {number} [options.manifest_version]
* @param {object} [options.customCSP]
* @param {TestPolicyExpects} options.expects
+ * @param {boolean} [options.temporarilyInstalled]
*/
async function testPolicy({
manifest_version = 2,
customCSP = null,
expects = {},
+ temporarilyInstalled = false,
}) {
info(
`Enter tests for extension CSP with ${JSON.stringify({
manifest_version,
customCSP,
+ temporarilyInstalled,
})}`
);
@@ -103,11 +123,23 @@ async function testPolicy({
);
}
+ function getBaseCsp() {
+ if (manifest_version === 2) {
+ return baseCSP.v2;
+ }
+
+ if (temporarilyInstalled) {
+ return baseCSP.v3_with_localhost;
+ }
+
+ return baseCSP.v3;
+ }
+
function checkCSP(csp, location) {
let policies = csp["csp-policies"];
info(`Base policy for ${location}`);
- let base = baseCSP[manifest_version];
+ let base = getBaseCsp();
equal(policies[0]["report-only"], false, "Policy is not report-only");
for (let key in base) {
@@ -139,6 +171,8 @@ async function testPolicy({
browser.test.sendMessage("worker-csp", event.data);
};
+ browser.test.sendMessage("localhost-csp", typeof y !== "undefined");
+
worker.postMessage({});
}
@@ -193,7 +227,9 @@ async function testPolicy({
files: {
"tab.html": `<html><head><meta charset="utf-8">
- <script src="tab.js"></${"script"}></head></html>`,
+ <script src="http://127.0.0.1:${server.identity.primaryPort}/local.js"></${"script"}>
+ <script src="tab.js"></${"script"}>
+ </head><body></body></html>`,
"tab.js": tabScript,
@@ -206,6 +242,7 @@ async function testPolicy({
content_security_policy,
web_accessible_resources,
},
+ temporarilyInstalled,
});
function frameScript() {
@@ -228,8 +265,17 @@ async function testPolicy({
info(`Testing CSP for policy: ${JSON.stringify(content_security_policy)}`);
+ // As temporarily installed manifest V3 extensions will intentionally
+ // present warnings when localhost URLs are specified in the CSP,
+ // we want to disable failure-by-warning for these tests.
+ ExtensionTestUtils.failOnSchemaWarnings(
+ !temporarilyInstalled || manifest_version !== 3 || !expects.localhostAllowed
+ );
+
await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
baseURL = await extension.awaitMessage("base-url");
let tabPage = await ExtensionTestUtils.loadContentPage(
@@ -266,6 +312,9 @@ async function testPolicy({
checkCSP(contentCSP, "content frame");
+ let localhostAllowed = await extension.awaitMessage("localhost-csp");
+ equal(localhostAllowed, expects.localhostAllowed, "localhost allowed");
+
let workerCSP = await extension.awaitMessage("worker-csp");
equal(
workerCSP.importScriptsAllowed,
@@ -291,6 +340,7 @@ add_task(async function testCSP() {
workerEvalAllowed: false,
workerImportAllowed: false,
workerWasmAllowed: true,
+ localhostAllowed: false,
},
});
@@ -306,6 +356,7 @@ add_task(async function testCSP() {
workerEvalAllowed: true,
workerImportAllowed: false,
workerWasmAllowed: true,
+ localhostAllowed: false,
},
});
@@ -318,6 +369,21 @@ add_task(async function testCSP() {
workerEvalAllowed: false,
workerImportAllowed: false,
workerWasmAllowed: true,
+ localhostAllowed: false,
+ },
+ });
+
+ await testPolicy({
+ manifest_version: 2,
+ customCSP: {
+ "script-src": `'self' http://127.0.0.1:${server.identity.primaryPort}`,
+ },
+ expects: {
+ workerEvalAllowed: false,
+ // importScripts() of localhost URL is allowed by the CSP
+ workerImportAllowed: true,
+ workerWasmAllowed: true,
+ localhostAllowed: true,
},
});
@@ -331,6 +397,7 @@ add_task(async function testCSP() {
workerEvalAllowed: false,
workerImportAllowed: false,
workerWasmAllowed: false,
+ localhostAllowed: false,
},
});
@@ -344,6 +411,7 @@ add_task(async function testCSP() {
workerEvalAllowed: false,
workerImportAllowed: false,
workerWasmAllowed: false,
+ localhostAllowed: false,
},
});
@@ -357,6 +425,33 @@ add_task(async function testCSP() {
workerEvalAllowed: false,
workerImportAllowed: false,
workerWasmAllowed: true,
+ localhostAllowed: false,
+ },
+ });
+
+ await testPolicy({
+ manifest_version: 3,
+ customCSP: null,
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: false,
+ workerWasmAllowed: false,
+ localhostAllowed: false,
+ },
+ temporarilyInstalled: true,
+ });
+
+ await testPolicy({
+ manifest_version: 3,
+ customCSP: {
+ "script-src": `'self' http://127.0.0.1:${server.identity.primaryPort}`,
+ },
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: true,
+ workerWasmAllowed: false,
+ localhostAllowed: true,
},
+ temporarilyInstalled: true,
});
});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
@@ -3,6 +3,7 @@
"use strict";
Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+AddonTestUtils.init(this);
add_task(async function test_manifest_csp() {
let normalized = await ExtensionTestUtils.normalizeManifest({
@@ -111,4 +112,54 @@ add_task(async function test_manifest_csp_v3() {
],
"Should have the expected warning for extension_pages CSP"
);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "script-src 'self' http://localhost:8080;",
+ },
+ },
+ temporarilyInstalled: true,
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Assert.deepEqual(
+ extension.extension.warnings,
+ "Reading manifest: Warning processing content_security_policy.extension_pages: Warning processing content_security_policy.extension_pages: " +
+ "Using localhost in the Content Security Policy is invalid, and is only permitted during development with temporarily loaded add-ons",
+ "Expected manifest warnings"
+ );
+
+ await extension.unload();
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "script-src 'self' http://localhost:8080;",
+ },
+ },
+ temporarilyInstalled: false,
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Reading manifest: Error processing content_security_policy.extension_pages: Using localhost in the Content Security Policy is invalid, and is only permitted during development with temporarily loaded add-ons/,
+ },
+ ],
+ });
+
+ await extension.unload();
});