Bug 1685123 - Implement manifest sandbox support r=robwu

Differential Revision: https://phabricator.services.mozilla.com/D308216
This commit is contained in:
Robbendebiene
2026-07-07 21:10:16 +00:00
committed by rob@robwu.nl
parent 83d64ccda0
commit bfa5336c09
17 changed files with 883 additions and 23 deletions
+8 -2
View File
@@ -85,8 +85,7 @@ interface nsIAddonContentPolicy : nsISupports
/* options to pass to validateAddonCSP
*
* Manifest V2 uses CSP_ALLOW_ANY.
* In Manifest V3, extension_pages would use CSP_ALLOW_WASM
* and sandbox would use CSP_ALLOW_EVAL.
* In Manifest V3, extension_pages would use CSP_ALLOW_WASM.
*/
const unsigned long CSP_ALLOW_ANY = 0xFFFF;
const unsigned long CSP_ALLOW_LOCALHOST = (1<<0);
@@ -100,4 +99,11 @@ interface nsIAddonContentPolicy : nsISupports
* string describing the error for invalid policies.
*/
AString validateAddonCSP(in AString aPolicyString, in unsigned long aPermittedPolicy);
/**
* Checks a custom content security policy string, to ensure that it meets
* minimum security requirements for sandboxed extensions. Returns null for
* valid policies, or a string describing the error for invalid policies.
*/
AString validateAddonSandboxCSP(in AString aPolicyString);
};
+6 -3
View File
@@ -3860,9 +3860,12 @@ nsresult Document::InitCSP(nsIChannel* aChannel) {
// ----- if the doc is an addon, apply its CSP.
if (addonPolicy) {
csp->AppendPolicy(addonPolicy->BaseCSP(), false, false);
csp->AppendPolicy(addonPolicy->ExtensionPageCSP(), false, false);
if (addonPolicy->Core()->IsSandboxPage(mDocumentURI)) {
csp->AppendPolicy(addonPolicy->SandboxPageCSP(), false, false);
} else {
csp->AppendPolicy(addonPolicy->BaseCSP(), false, false);
csp->AppendPolicy(addonPolicy->ExtensionPageCSP(), false, false);
}
}
// ----- if there's a full-strength CSP header, apply it.
@@ -91,6 +91,15 @@ interface WebExtensionPolicy {
[Constant]
readonly attribute DOMString extensionPageCSP;
/**
* The content security policy string to apply to all sandboxed pages loaded from the
* extension. This is set in the extension manifest.
* If one is not provided by the extension it falls back to:
* "sandbox allow-scripts; script-src 'self';".
*/
[Constant]
readonly attribute DOMString sandboxPageCSP;
/**
* The list of currently-active permissions for the extension, as specified
* in its manifest.json file. May be updated to reflect changes in the
@@ -377,6 +386,9 @@ dictionary WebExtensionInit {
unsigned long manifestVersion = 2;
DOMString? extensionPageCSP = null;
DOMString? sandboxPageCSP = null;
sequence<MatchGlobOrString>? sandboxPages = null;
sequence<DOMString>? backgroundScripts = null;
DOMString? backgroundWorkerScript = null;
@@ -2224,6 +2224,14 @@ export class ExtensionData {
})
);
}
const sandboxPages = manifest.sandbox?.pages;
if (sandboxPages) {
// Normalize all paths to contain a single leading /
result.sandboxPages = sandboxPages.map(path =>
path.replace(/^\/*/, "/")
);
}
} else if (this.type == "locale") {
// Langpack startup is performance critical, so we want to compute as much
// as possible here to make startup not trigger async DB reads.
@@ -2354,6 +2362,7 @@ export class ExtensionData {
await this.apiManager.lazyInit();
this.webAccessibleResources = manifestData.webAccessibleResources;
this.sandboxPages = manifestData.sandboxPages;
this.originControls = manifestData.originControls;
this.allowedOrigins = new MatchPatternSet(manifestData.originPermissions, {
@@ -3510,6 +3519,7 @@ export class Extension extends ExtensionData {
this.allowedOrigins = null;
this._optionalOrigins = null;
this.webAccessibleResources = null;
this.sandboxPages = null;
this.registeredContentScripts = new Map();
@@ -3763,6 +3773,13 @@ export class Extension extends ExtensionData {
return content_security_policy;
}
get sandboxPageCSP() {
if (this.manifestVersion === 2) {
return this.manifest.sandbox?.content_security_policy;
}
return this.manifest.content_security_policy?.sandbox;
}
get backgroundScripts() {
return this.manifest.background?.scripts;
}
@@ -3825,6 +3842,8 @@ export class Extension extends ExtensionData {
type: this.type,
manifestVersion: this.manifestVersion,
extensionPageCSP: this.extensionPageCSP,
sandboxPageCSP: this.sandboxPageCSP,
sandboxPages: this.sandboxPages,
instanceId: this.instanceId,
resourceURL: this.resourceURL,
contentScripts: this.contentScripts,
@@ -159,7 +159,8 @@ ExtensionManager = {
manifestVersion: extension.manifestVersion,
extensionPageCSP: extension.extensionPageCSP,
sandboxPageCSP: extension.sandboxPageCSP,
sandboxPages: extension.sandboxPages,
localizeCallback,
backgroundScripts,
+10 -2
View File
@@ -1278,8 +1278,8 @@ const FORMATS = {
},
contentSecurityPolicy(string, context) {
// Manifest V3 extension_pages allows WASM. When sandbox is
// implemented, or any other V3 or later directive, the flags
// Manifest V3 extension_pages allows WASM. When any other V3
// or later directive is implemented, the flags
// logic will need to be updated.
let flags =
@@ -1322,6 +1322,14 @@ const FORMATS = {
return string;
},
contentSecurityPolicySandbox(string) {
const error = lazy.contentPolicyService.validateAddonSandboxCSP(string);
if (error != null) {
throw new Error(error);
}
return string;
},
date(string) {
// A valid ISO 8601 timestamp.
const PATTERN =
@@ -68,6 +68,8 @@ static const char kBackgroundPageHTMLEnd[] =
"script-src 'self' 'wasm-unsafe-eval' http://localhost:* " \
"http://127.0.0.1:*;"
#define DEFAULT_SANDBOX_CSP "sandbox allow-scripts; script-src 'self';"
static inline ExtensionPolicyService& EPS() {
return ExtensionPolicyService::GetSingleton();
}
@@ -93,13 +95,14 @@ static nsISubstitutingProtocolHandler* Proto() {
bool ParseGlobs(GlobalObject& aGlobal,
Sequence<OwningMatchGlobOrUTF8String> aGlobs,
nsTArray<RefPtr<MatchGlobCore>>& aResult, ErrorResult& aRv) {
nsTArray<RefPtr<MatchGlobCore>>& aResult, ErrorResult& aRv,
bool aAllowQuestion = true) {
for (auto& elem : aGlobs) {
if (elem.IsMatchGlob()) {
aResult.AppendElement(elem.GetAsMatchGlob()->Core());
} else {
RefPtr<MatchGlobCore> glob =
new MatchGlobCore(elem.GetAsUTF8String(), true, false, aRv);
new MatchGlobCore(elem.GetAsUTF8String(), aAllowQuestion, false, aRv);
if (aRv.Failed()) {
return false;
}
@@ -213,6 +216,7 @@ WebExtensionPolicyCore::WebExtensionPolicyCore(GlobalObject& aGlobal,
mType(NS_AtomizeMainThread(aInit.mType)),
mManifestVersion(aInit.mManifestVersion),
mExtensionPageCSP(aInit.mExtensionPageCSP),
mSandboxPageCSP(aInit.mSandboxPageCSP),
mIsPrivileged(aInit.mIsPrivileged),
mTemporarilyInstalled(aInit.mTemporarilyInstalled),
mBackgroundWorkerScript(aInit.mBackgroundWorkerScript),
@@ -243,6 +247,17 @@ WebExtensionPolicyCore::WebExtensionPolicyCore(GlobalObject& aGlobal,
}
}
if (!aInit.mSandboxPages.IsNull()) {
if (!ParseGlobs(aGlobal, aInit.mSandboxPages.Value(),
mSandboxPages.SetValue(), aRv, false)) {
return;
}
}
if (mSandboxPageCSP.IsVoid()) {
mSandboxPageCSP.AssignLiteral(DEFAULT_SANDBOX_CSP);
}
if (mExtensionPageCSP.IsVoid()) {
if (mManifestVersion < 3) {
EPS().GetDefaultCSP(mExtensionPageCSP);
@@ -267,6 +282,14 @@ WebExtensionPolicyCore::WebExtensionPolicyCore(GlobalObject& aGlobal,
}
}
bool WebExtensionPolicyCore::IsSandboxPage(nsIURI* aURI) const {
extensions::URLInfo urlInfo(aURI);
return aURI && !mSandboxPages.IsNull() &&
urlInfo.Scheme() == nsGkAtoms::moz_extension &&
MozExtensionHostname().Equals(urlInfo.Host()) &&
mSandboxPages.Value().Matches(urlInfo.FilePath());
}
bool WebExtensionPolicyCore::SourceMayAccessPath(
const URLInfo& aURI, const nsACString& aPath) const {
if (aURI.Scheme() == nsGkAtoms::moz_extension &&
@@ -94,6 +94,8 @@ class WebExtensionPolicyCore final {
const nsString& BaseCSP() const { return mBaseCSP; }
const nsString& SandboxPageCSP() const { return mSandboxPageCSP; }
const nsString& BackgroundWorkerScript() const {
return mBackgroundWorkerScript;
}
@@ -107,6 +109,8 @@ class WebExtensionPolicyCore final {
return false;
}
bool IsSandboxPage(nsIURI* aURL) const;
bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) const;
bool HasPermission(const nsAtom* aPermission) const {
@@ -198,6 +202,9 @@ class WebExtensionPolicyCore final {
/* const */ nsString mExtensionPageCSP;
/* const */ nsString mBaseCSP;
/* const */ nsString mSandboxPageCSP;
/* const */ dom::Nullable<MatchGlobSet> mSandboxPages;
const bool mIsPrivileged;
const bool mTemporarilyInstalled;
@@ -319,6 +326,9 @@ class WebExtensionPolicy final : public nsISupports, public nsWrapperCache {
const nsString& BaseCSP() const { return mCore->BaseCSP(); }
void GetBaseCSP(nsAString& aCSP) const { aCSP = BaseCSP(); }
const nsString& SandboxPageCSP() const { return mCore->SandboxPageCSP(); }
void GetSandboxPageCSP(nsAString& aCSP) const { aCSP = SandboxPageCSP(); }
already_AddRefed<MatchPatternSet> AllowedOrigins() {
return do_AddRef(mHostPermissions);
}
@@ -231,12 +231,40 @@
"optional": true,
"format": "contentSecurityPolicy",
"description": "The Content Security Policy used for extension pages."
},
"sandbox": {
"type": "string",
"optional": true,
"format": "contentSecurityPolicySandbox",
"description": "The content security policy used for sandboxed extension pages."
}
}
}
]
},
"sandbox": {
"type": "object",
"optional": true,
"additionalProperties": {
"$ref": "UnrecognizedProperty"
},
"properties": {
"pages": {
"type": "array",
"items": { "type": "string" },
"description": "The list of pages in the form of globs to serve as sandboxed extension pages."
},
"content_security_policy": {
"type": "string",
"optional": true,
"max_manifest_version": 2,
"format": "contentSecurityPolicySandbox",
"description": "The content security policy used for sandboxed extension pages."
}
}
},
"permissions": {
"default": [],
"optional": true,
@@ -33,6 +33,7 @@ async function testExecuteScript({
manifest_version,
userScript = false,
activeTabPermission = false,
sandbox = false,
}) {
const TEST_SUBFRAME_URL = "https://example.com/?test=mozExtIframe";
const EXPECTED_ERROR_MESSAGE = "Missing host permission for the tab";
@@ -62,6 +63,12 @@ async function testExecuteScript({
manifestPart.host_permissions = ["https://example.com/*"];
}
if (sandbox) {
manifestPart.sandbox = {
pages: ["extpage.html", "extpage-sandboxed.html"],
};
}
const web_accessible_resources =
manifest_version === 3
? [
@@ -172,10 +179,18 @@ async function testExecuteScript({
files: {
"extpage.html": `<script src='extpage.js'><\/script>`,
"extpage.js": function () {
browser.test.sendMessage("extpage:ready");
window.top.postMessage("extpage:ready", "*");
},
"extpage-sandboxed.html": `<script src='extpage-sandboxed.js'><\/script>`,
"extpage-sandboxed.js": function () {
window.top.postMessage("sandboxed-subframe:ready", "*");
},
"extpage-sandboxed.html": `<h1>sandboxed iframe</h1>`,
"createSubFrame.js": function () {
// forward post message events
// eslint-disable-next-line mozilla/balanced-listeners
window.addEventListener("message", event => {
browser.test.sendMessage(event.data);
});
const iframeExt = document.createElement("iframe");
iframeExt.src = browser.runtime.getURL("extpage.html");
const iframeExtSandboxed = document.createElement("iframe");
@@ -183,14 +198,7 @@ async function testExecuteScript({
"extpage-sandboxed.html"
);
iframeExtSandboxed.setAttribute("sandbox", "allow-scripts");
const promiseSandboxFrameLoaded = new Promise(resolve =>
iframeExtSandboxed.addEventListener("load", resolve, { once: true })
);
document.body.append(iframeExtSandboxed);
document.body.append(iframeExt);
promiseSandboxFrameLoaded.then(() =>
browser.test.sendMessage("sandboxed-subframe:ready")
);
document.body.append(iframeExtSandboxed, iframeExt);
},
},
});
@@ -216,8 +224,6 @@ async function testExecuteScript({
? "userScripts.execute"
: "scripting.executeScript";
await extension.awaitMessage("extpage:ready");
if (manifest_version < 3) {
extension.sendMessage("tabs.executeScript", {
activeTabPermission,
@@ -340,6 +346,53 @@ add_task(
}
);
add_task(function test_csp_sandbox_mv2_executeScript() {
return testExecuteScript({
manifest_version: 2,
sandbox: true,
});
});
add_task(function test_csp_sandbox_mv3_executeScript() {
return testExecuteScript({
manifest_version: 3,
sandbox: true,
});
});
add_task(function test_csp_sandbox_activeTab_mv2_executeScript() {
return testExecuteScript({
manifest_version: 2,
sandbox: true,
activeTabPermission: true,
});
});
add_task(function test_csp_sandbox_activeTab_mv3_executeScript() {
return testExecuteScript({
manifest_version: 3,
sandbox: true,
activeTabPermission: true,
});
});
add_task(function test_csp_sandbox_mv3_userScripts_execute() {
return testExecuteScript({
manifest_version: 3,
sandbox: true,
userScript: true,
});
});
add_task(function test_csp_sandbox_activeTab_mv3_userScripts_execute() {
return testExecuteScript({
manifest_version: 3,
sandbox: true,
userScript: true,
activeTabPermission: true,
});
});
add_task(async function testContentScriptsAndUserScriptsRegister() {
const extensionMV2 = ExtensionTestUtils.loadExtension({
manifest: {
@@ -329,3 +329,43 @@ add_task(async function test_csp_validator_extension_pages() {
);
}
});
add_task(async function test_csp_validator_sandbox_pages() {
const checkPolicy = (policy, expectedResult) => {
info(`Checking policy: ${policy}`);
const result = cps.validateAddonSandboxCSP(policy);
equal(result, expectedResult);
};
// Validate hard coded default csp
const defaultPolicy = "sandbox allow-scripts; script-src 'self';";
checkPolicy(defaultPolicy, null);
for (const policy of [
"script-src 'self';",
"script-src 'self'; default-src 'self';",
]) {
checkPolicy(
policy,
"Policy is missing a required \u2018sandbox\u2019 directive"
);
checkPolicy(`sandbox allow-scripts; ${policy}`, null);
}
for (const policy of [
"sandbox allow-same-origin;",
"sandbox allow-scripts allow-same-origin;",
]) {
checkPolicy(
policy,
"sandbox directive contains a forbidden 'allow-same-origin' keyword"
);
}
// check with all existing sandbox flags except for allow-same-origin
checkPolicy(
`sandbox allow-downloads allow-downloads-without-user-activation allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols; script-src 'unsafe-inline' 'unsafe-eval' https: http: data: blob: 'self';`,
null
);
});
@@ -160,3 +160,117 @@ add_task(async function test_manifest_csp_v3() {
await extension.unload();
});
async function testForValidSandboxCSP({ manifest_version, csp }) {
const manifest = {
manifest_version,
sandbox: {
pages: ["sandbox.html"],
},
};
if (manifest_version < 3) {
manifest.sandbox.content_security_policy = csp;
} else {
manifest.content_security_policy = { sandbox: csp };
}
ExtensionTestUtils.failOnSchemaWarnings(false);
const normalized = await ExtensionTestUtils.normalizeManifest(manifest);
ExtensionTestUtils.failOnSchemaWarnings(true);
equal(normalized.error, undefined, "Should not have an error");
equal(normalized.errors.length, 0, "Should not have warnings");
equal(
manifest_version < 3
? normalized.value.sandbox.content_security_policy
: normalized.value.content_security_policy.sandbox,
csp,
"Should have the expected policy string"
);
}
add_task(async function test_valid_sandbox_csp_mv2() {
await testForValidSandboxCSP({
manifest_version: 2,
csp: "sandbox allow-scripts; script-src 'self' 'unsafe-eval' 'unsafe-inline';",
});
});
add_task(async function test_valid_sandbox_csp_mv3() {
await testForValidSandboxCSP({
manifest_version: 3,
csp: "sandbox allow-scripts; script-src 'self' 'unsafe-eval' 'unsafe-inline';",
});
});
async function testForInvalidSandboxCSP({
manifest_version,
csp,
expectedError,
}) {
const manifest = {
manifest_version,
sandbox: {
pages: ["sandbox.html"],
},
};
if (manifest_version < 3) {
manifest.sandbox.content_security_policy = csp;
} else {
manifest.content_security_policy = { sandbox: csp };
}
ExtensionTestUtils.failOnSchemaWarnings(false);
const normalized = await ExtensionTestUtils.normalizeManifest(manifest);
ExtensionTestUtils.failOnSchemaWarnings(true);
equal(normalized.errors.length, 0, "Should not have warnings");
equal(normalized.error, expectedError, "Should have the expected error");
equal(normalized.value, undefined, "Manifest parsing should have failed");
}
add_task(async function test_empty_sandbox_csp_mv2() {
await testForInvalidSandboxCSP({
manifest_version: 2,
csp: "",
expectedError:
"Error processing sandbox.content_security_policy: Error: Policy is missing a required sandbox directive",
});
});
add_task(async function test_empty_sandbox_csp_mv3() {
await testForInvalidSandboxCSP({
manifest_version: 3,
csp: "",
expectedError:
"Error processing content_security_policy.sandbox: Error: Policy is missing a required sandbox directive",
});
});
add_task(async function test_missing_sandbox_csp_mv2() {
await testForInvalidSandboxCSP({
manifest_version: 2,
csp: "script-src 'self'; object-src 'none'",
expectedError:
"Error processing sandbox.content_security_policy: Error: Policy is missing a required sandbox directive",
});
});
add_task(async function test_missing_sandbox_csp_mv3() {
await testForInvalidSandboxCSP({
manifest_version: 3,
csp: "script-src 'self'; object-src 'none'",
expectedError:
"Error processing content_security_policy.sandbox: Error: Policy is missing a required sandbox directive",
});
});
add_task(async function test_disallowed_allow_same_origin_mv2() {
await testForInvalidSandboxCSP({
manifest_version: 2,
csp: "sandbox allow-same-origin;",
expectedError:
"Error processing sandbox.content_security_policy: Error: sandbox directive contains a forbidden 'allow-same-origin' keyword",
});
});
add_task(async function test_disallowed_allow_same_origin_mv3() {
await testForInvalidSandboxCSP({
manifest_version: 3,
csp: "sandbox allow-same-origin;",
expectedError:
"Error processing content_security_policy.sandbox: Error: sandbox directive contains a forbidden 'allow-same-origin' keyword",
});
});
@@ -0,0 +1,472 @@
"use strict";
// helper to create respective manifests for manifest_version 2 and 3
function createSandboxManifest({ manifest_version, csp, pages }) {
const manifest = {
manifest_version,
sandbox: {
pages,
},
};
if (manifest_version < 3) {
manifest.sandbox.content_security_policy = csp;
} else {
manifest.content_security_policy = { sandbox: csp };
}
return manifest;
}
add_task(async function test_default_sandbox_csp() {
const defaultSandboxCSP = "sandbox allow-scripts; script-src 'self';";
for (const manifest_version of [2, 3]) {
info(
`Testing default sandbox CSP for manifest version ${manifest_version}`
);
const extension = ExtensionTestUtils.loadExtension({});
await extension.startup();
const policy = WebExtensionPolicy.getByID(extension.id);
equal(policy.sandboxPageCSP, defaultSandboxCSP, "sandboxCSP is correct.");
await extension.unload();
}
});
add_task(async function test_sandbox_null_origin() {
const server = createHttpServer();
server.registerPathHandler("/return_origin_header", (request, response) => {
response.setHeader("Content-Type", "text/plain");
response.setHeader("Access-Control-Allow-Origin", "*", false);
response.write(request.getHeader("Origin"));
});
server.registerPathHandler("/message_origin.html", (request, response) => {
response.setHeader("Content-Type", "text/html");
response.setHeader("Access-Control-Allow-Origin", "*", false);
response.write(
`<script>window.top.postMessage(window.origin, "*");</script>`
);
});
const BASE_URL = `http://localhost:${server.identity.primaryPort}`;
// required to load iframe
allow_unsafe_parent_loads_when_extensions_not_remote();
for (const manifest_version of [2, 3]) {
info(
`Testing sandbox "null" origin for manifest version ${manifest_version}`
);
const extension = ExtensionTestUtils.loadExtension({
manifest: createSandboxManifest({
manifest_version,
pages: ["sandbox.html"],
}),
files: {
"sandbox.html": `<!DOCTYPE html><title>x</title>`,
},
});
await extension.startup();
const contentPage = await ExtensionTestUtils.loadContentPage(
extension.extension.getURL("sandbox.html")
);
const noExtensionAPI = await contentPage.spawn([], () => {
const w = content.window.wrappedJSObject;
return w.browser === undefined && w.chrome === undefined;
});
equal(noExtensionAPI, true, "Check that extension API is not exposed");
const windowOrigin = await contentPage.spawn([], () => {
return content.window.wrappedJSObject.origin;
});
equal(windowOrigin, "null", "Check window origin");
const requestOrigin = await contentPage.spawn([BASE_URL], async base => {
const response = await content.window.wrappedJSObject.fetch(
`${base}/return_origin_header`
);
return response.text();
});
equal(requestOrigin, "null", "Check origin request header");
const iframeOrigin = await contentPage.spawn([BASE_URL], base => {
const { window, document } = content.window.wrappedJSObject;
return new Promise((resolve, reject) => {
window.addEventListener("message", event => resolve(event.data), {
once: true,
});
const iframe = document.createElement("iframe");
iframe.src = `${base}/message_origin.html`;
iframe.onerror = reject;
document.body.append(iframe);
});
});
equal(
iframeOrigin,
"null",
"Web page in iframe inherits CSP sandbox from sandboxed extension document"
);
await contentPage.close();
await extension.unload();
}
revert_allow_unsafe_parent_loads_when_extensions_not_remote();
});
add_task(async function test_sandbox_csp() {
const server = createHttpServer();
server.registerPathHandler("/script_sets_var.js", (request, response) => {
response.setHeader("Content-Type", "text/javascript");
response.setHeader("Access-Control-Allow-Origin", "*", false);
response.write(`window.testRemoteScript = true;`);
});
const BASE_URL = `http://localhost:${server.identity.primaryPort}`;
const sandboxCSP = "sandbox allow-scripts; script-src 'self'";
const TESTS = [
{
description: "Test eval.",
relaxedPageCSP: `${sandboxCSP} 'unsafe-eval';`,
restrictedPageCSP: sandboxCSP,
violatedDirective: "script-src",
injectInto: contentPage =>
contentPage.spawn([], () => {
const { window } = content.window.wrappedJSObject;
try {
// eslint-disable-next-line no-eval
return window.eval("true");
} catch (e) {
return false;
}
}),
},
{
description: "Test inline script injection.",
relaxedPageCSP: `${sandboxCSP} 'unsafe-inline';`,
restrictedPageCSP: sandboxCSP,
violatedDirective: "script-src-elem",
injectInto: contentPage =>
contentPage.spawn([], () => {
const { window, document } = content.window.wrappedJSObject;
const script = document.createElement("script");
script.textContent = "window.testInlineScript = true;";
document.body.append(script);
return window.testInlineScript ?? false;
}),
},
{
description: "Test data URL script injection.",
relaxedPageCSP: `${sandboxCSP} data:;`,
restrictedPageCSP: sandboxCSP,
violatedDirective: "script-src-elem",
injectInto: contentPage =>
contentPage.spawn([], () => {
const { window, document } = content.window.wrappedJSObject;
return new Promise(resolve => {
const script = document.createElement("script");
script.src =
"data:text/javascript;base64," +
window.btoa("window.testDataURLScript = true;");
script.onload = () => {
resolve(window.testDataURLScript ?? false);
};
script.onerror = () => resolve(false);
document.body.append(script);
});
}),
},
{
description: " Test remote script injection.",
relaxedPageCSP: `${sandboxCSP} http://localhost:*;`,
restrictedPageCSP: sandboxCSP,
violatedDirective: "script-src-elem",
injectInto: contentPage =>
contentPage.spawn([`${BASE_URL}/script_sets_var.js`], url => {
const { window, document } = content.window.wrappedJSObject;
return new Promise(resolve => {
const script = document.createElement("script");
script.src = url;
script.onload = () => {
resolve(window.testRemoteScript ?? false);
};
script.onerror = () => resolve(false);
document.body.append(script);
});
}),
},
];
async function runWithRelaxedCSP(test, manifest_version) {
const extension = ExtensionTestUtils.loadExtension({
manifest: {
...createSandboxManifest({
manifest_version,
pages: ["sandbox.html"],
csp: test.relaxedPageCSP,
}),
host_permissions: ["http://localhost/*"],
},
files: {
"sandbox.html": `<!DOCTYPE html><title>x</title>`,
},
});
await extension.startup();
const contentPage = await ExtensionTestUtils.loadContentPage(
extension.extension.getURL("sandbox.html")
);
const result = await test.injectInto(contentPage);
equal(result, true, test.description);
await contentPage.close();
await extension.unload();
}
async function runWithRestrictedCSP(test, manifest_version) {
const extension = ExtensionTestUtils.loadExtension({
manifest: {
...createSandboxManifest({
manifest_version,
pages: ["sandbox.html"],
csp: test.restrictedPageCSP,
}),
host_permissions: ["http://localhost/*"],
},
files: {
"sandbox.html": `<!DOCTYPE html><title>x</title>`,
},
});
await extension.startup();
const contentPage = await ExtensionTestUtils.loadContentPage(
extension.extension.getURL("sandbox.html")
);
const awaitViolation = contentPage.spawn([], () => {
return new Promise(resolve => {
content.document.addEventListener(
"securitypolicyviolation",
e => {
resolve(e.violatedDirective);
},
{ once: true }
);
});
});
const result = await test.injectInto(contentPage);
equal(result, false, test.description);
equal(
await awaitViolation,
test.violatedDirective,
"violation in correct directive"
);
await contentPage.close();
await extension.unload();
}
for (const test of TESTS) {
info(`Running: ${test.description}`);
for (const manifest_version of [2, 3]) {
info(`Testing with manifest version ${manifest_version}`);
info(`Testing with relaxed CSP: ${test.relaxedPageCSP}`);
await runWithRelaxedCSP(test, manifest_version);
info(`Testing with restricted CSP: ${test.restrictedPageCSP}`);
await runWithRestrictedCSP(test, manifest_version);
}
}
});
async function embedExtensionPageFromSandbox({
manifest_version,
isWebAccessible = false,
}) {
const manifest = createSandboxManifest({
manifest_version,
pages: ["sandbox.html"],
});
if (isWebAccessible) {
manifest.web_accessible_resources =
manifest_version < 3
? ["privileged.html"]
: [
{
resources: ["privileged.html"],
// TODO bug 2052564: Stop requiring <all_urls> for exposing to a sandboxed document.
matches: ["<all_urls>"],
},
];
}
const extension = ExtensionTestUtils.loadExtension({
manifest,
files: {
"sandbox.html": `<!DOCTYPE html><html>
<head><meta charset="UTF-8"></head>
<body></body>
`,
// Cannot use browser.test here due to bug 1896824
"privileged.html": `<script src='privileged.js'><\/script>`,
"privileged.js": function () {
window.top.postMessage(typeof browser != "undefined", "*");
},
},
});
await extension.startup();
const sandboxURL = extension.extension.getURL("sandbox.html");
const sandboxPage = await ExtensionTestUtils.loadContentPage(sandboxURL);
const isPrivileged = await sandboxPage.spawn([], () => {
return new Promise(resolve => {
const { window, document } = content;
window.addEventListener("message", event => resolve(event.data), {
once: true,
});
const iframe = document.createElement("iframe");
iframe.src = "privileged.html";
iframe.onerror = () => resolve(null);
document.body.append(iframe);
});
});
if (isWebAccessible) {
equal(
isPrivileged,
false,
"Privileged page was loaded without extension API access."
);
} else {
equal(isPrivileged, null, "Privileged page loading was blocked.");
}
await sandboxPage.close();
await extension.unload();
}
add_task(async function test_sandbox_embedding_privileged_page_mv2() {
await embedExtensionPageFromSandbox({ manifest_version: 2 });
});
add_task(async function test_sandbox_embedding_privileged_page_mv3() {
await embedExtensionPageFromSandbox({ manifest_version: 3 });
});
add_task(async function test_sandbox_embedding_web_accessible_page_mv2() {
await embedExtensionPageFromSandbox({
manifest_version: 2,
isWebAccessible: true,
});
});
add_task(async function test_sandbox_embedding_web_accessible_page_mv3() {
await embedExtensionPageFromSandbox({
manifest_version: 3,
isWebAccessible: true,
});
});
add_task(async function test_sandbox_pages() {
const TESTS = [
{
pages: [],
isSandboxed: {
page01: false,
page02: false,
},
},
{
pages: ["page01.html"],
isSandboxed: {
page01: true,
page02: false,
},
},
{
pages: ["/page01.html"],
isSandboxed: {
page01: true,
page02: false,
},
},
{
pages: ["/page01.html", "/page02.html"],
isSandboxed: {
page01: true,
page02: true,
},
},
{
pages: ["/page*.html"],
isSandboxed: {
page01: true,
page02: true,
},
},
{
pages: ["*"],
isSandboxed: {
page01: true,
page02: true,
},
},
{
pages: ["/page??.html"],
isSandboxed: {
page01: false,
page02: false,
},
},
];
for (const test of TESTS) {
for (const manifest_version of [2, 3]) {
info(
`Testing sandbox pages: ${test.pages.join(", ")} for manifest version ${manifest_version}`
);
const extension = ExtensionTestUtils.loadExtension({
manifest: createSandboxManifest({
manifest_version,
pages: test.pages,
}),
files: {
"page01.html": `<!DOCTYPE html><title>01</title>`,
"page02.html": `<!DOCTYPE html><title>02</title>`,
},
});
await extension.startup();
const contentPage01 = await ExtensionTestUtils.loadContentPage(
extension.extension.getURL("page01.html")
);
const page01IsSandboxed = await contentPage01.spawn(
[],
() => content.window.wrappedJSObject.origin === "null"
);
equal(
page01IsSandboxed,
test.isSandboxed.page01,
"page01.html is sandboxed"
);
const contentPage02 = await ExtensionTestUtils.loadContentPage(
extension.extension.getURL("page02.html?test=param#testHash")
);
const page02IsSandboxed = await contentPage02.spawn(
[],
() => content.window.wrappedJSObject.origin === "null"
);
equal(
page02IsSandboxed,
test.isSandboxed.page02,
"page02.html is sandboxed"
);
await contentPage01.close();
await contentPage02.close();
await extension.unload();
}
}
});
@@ -100,6 +100,8 @@ run-if = [
["test_ext_runtime_sendMessage_args.js"]
["test_ext_sandbox_csp.js"]
["test_ext_schemas.js"]
head = "head.js head_schemas.js"
@@ -137,8 +139,8 @@ skip-if = [
skip-if = [
"os == 'win' && os_version == '10.2009' && arch == 'x86_64'", # Bug 1996467
"os == 'win' && os_version == '11.26100' && arch == 'x86'", # Bug 1996467
"os == 'win' && os_version == '11.26200' && arch == 'x86'", # Bug 1996467
"os == 'win' && os_version == '11.26100' && arch == 'x86_64'", # Bug 1996467
"os == 'win' && os_version == '11.26200' && arch == 'x86'", # Bug 1996467
"os == 'win' && os_version == '11.26200' && arch == 'x86_64'", # Bug 1996467
]
@@ -17,6 +17,7 @@
#include "nsIURI.h"
#include "nsNetCID.h"
#include "nsNetUtil.h"
#include "nsSandboxFlags.h"
using namespace mozilla;
using namespace mozilla::intl;
@@ -367,3 +368,67 @@ AddonContentPolicy::ValidateAddonCSP(const nsAString& aPolicyString,
return NS_OK;
}
/**
* Validates a custom content security policy string for use by sandboxed add-on
* pages. The CSP must not contain allow-same-origin.
*/
NS_IMETHODIMP
AddonContentPolicy::ValidateAddonSandboxCSP(const nsAString& aPolicyString,
nsAString& aResult) {
nsresult rv;
// Validate against a randomly-generated extension origin.
// There is no add-on-specific behavior in the CSP code, beyond the ability
// for add-ons to specify a custom policy, but the parser requires a valid
// origin in order to operate correctly.
nsAutoString url(u"moz-extension://");
{
nsCOMPtr<nsIUUIDGenerator> uuidgen = components::UUIDGenerator::Service();
NS_ENSURE_TRUE(uuidgen, NS_ERROR_FAILURE);
nsID id;
rv = uuidgen->GenerateUUIDInPlace(&id);
NS_ENSURE_SUCCESS(rv, rv);
char idString[NSID_LENGTH];
id.ToProvidedString(idString);
MOZ_RELEASE_ASSERT(idString[0] == '{' && idString[NSID_LENGTH - 2] == '}',
"UUID generator did not return a valid UUID");
url.AppendASCII(idString + 1, NSID_LENGTH - 3);
}
RefPtr<BasePrincipal> principal =
BasePrincipal::CreateContentPrincipal(NS_ConvertUTF16toUTF8(url));
nsCOMPtr<nsIURI> selfURI;
principal->GetURI(getter_AddRefs(selfURI));
RefPtr<nsCSPContext> csp = new nsCSPContext();
rv = csp->SetRequestContextWithPrincipal(principal, selfURI, ""_ns, 0);
NS_ENSURE_SUCCESS(rv, rv);
csp->AppendPolicy(aPolicyString, false, false);
uint32_t sandboxFlags = 0;
rv = csp->GetCSPSandboxFlags(&sandboxFlags);
NS_ENSURE_SUCCESS(rv, rv);
// ensure sandbox without allow-same-origin
if (sandboxFlags & SANDBOXED_ORIGIN) {
aResult.SetIsVoid(true);
} else {
CSPDirective directive = nsIContentSecurityPolicy::SANDBOX_DIRECTIVE;
CSPValidator validator(url, directive, true,
nsIAddonContentPolicy::CSP_ALLOW_ANY);
// provide more detailed error when allow-same-origin is set
if (sandboxFlags != SANDBOXED_NONE) {
validator.FormatError("csp-error-illegal-keyword"_ns, "keyword"_ns,
u"'allow-same-origin'"_ns);
} else {
MOZ_ASSERT(!validator.GetError().IsVoid(),
"CSPValidator should have raised an error.");
}
aResult.Assign(validator.GetError());
}
return NS_OK;
}
+2
View File
@@ -4663,6 +4663,8 @@ interface WebExtensionInit {
baseURL: string;
contentScripts?: WebExtensionContentScriptInit[];
extensionPageCSP?: string | null;
sandboxPageCSP?: string | null;
sandboxPages?: MatchGlobOrString[] | null;
hasRecommendedState?: boolean;
id: string;
ignoreQuarantine?: boolean;
+2
View File
@@ -1177,6 +1177,8 @@ interface nsIAddonContentPolicy extends nsISupports {
readonly CSP_ALLOW_WASM?: 8;
validateAddonCSP(aPolicyString: string, aPermittedPolicy: u32): string;
validateAddonSandboxCSP(aPolicyString: string): string;
}
// https://searchfox.org/firefox-main/source/caps/nsIDomainPolicy.idl