From bfa5336c0905dc7389fa1d42340b09bbb1d4039b Mon Sep 17 00:00:00 2001 From: Robbendebiene Date: Tue, 7 Jul 2026 20:56:36 +0000 Subject: [PATCH] Bug 1685123 - Implement manifest sandbox support r=robwu Differential Revision: https://phabricator.services.mozilla.com/D308216 --- caps/nsIAddonPolicyService.idl | 10 +- dom/base/Document.cpp | 9 +- dom/chrome-webidl/WebExtensionPolicy.webidl | 12 + .../components/extensions/Extension.sys.mjs | 19 + .../extensions/ExtensionProcessScript.sys.mjs | 3 +- toolkit/components/extensions/Schemas.sys.mjs | 12 +- .../extensions/WebExtensionPolicy.cpp | 27 +- .../extensions/WebExtensionPolicy.h | 10 + .../extensions/schemas/manifest.json | 28 ++ .../test_ext_executeScript_mozextension.html | 77 ++- .../test/xpcshell/test_csp_validator.js | 40 ++ ...st_ext_manifest_content_security_policy.js | 114 +++++ .../test/xpcshell/test_ext_sandbox_csp.js | 472 ++++++++++++++++++ .../extensions/test/xpcshell/xpcshell.toml | 4 +- .../mozapps/extensions/AddonContentPolicy.cpp | 65 +++ tools/@types/generated/lib.gecko.dom.d.ts | 2 + tools/@types/generated/lib.gecko.xpcom.d.ts | 2 + 17 files changed, 883 insertions(+), 23 deletions(-) create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_sandbox_csp.js diff --git a/caps/nsIAddonPolicyService.idl b/caps/nsIAddonPolicyService.idl index 83a36b23b132..f95603800a5d 100644 --- a/caps/nsIAddonPolicyService.idl +++ b/caps/nsIAddonPolicyService.idl @@ -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); }; diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp index 42205042795e..dde3297bc456 100644 --- a/dom/base/Document.cpp +++ b/dom/base/Document.cpp @@ -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. diff --git a/dom/chrome-webidl/WebExtensionPolicy.webidl b/dom/chrome-webidl/WebExtensionPolicy.webidl index eb11b95d5df9..6269e683140c 100644 --- a/dom/chrome-webidl/WebExtensionPolicy.webidl +++ b/dom/chrome-webidl/WebExtensionPolicy.webidl @@ -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? sandboxPages = null; + sequence? backgroundScripts = null; DOMString? backgroundWorkerScript = null; diff --git a/toolkit/components/extensions/Extension.sys.mjs b/toolkit/components/extensions/Extension.sys.mjs index c4344b8d878e..f7739039f077 100644 --- a/toolkit/components/extensions/Extension.sys.mjs +++ b/toolkit/components/extensions/Extension.sys.mjs @@ -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, diff --git a/toolkit/components/extensions/ExtensionProcessScript.sys.mjs b/toolkit/components/extensions/ExtensionProcessScript.sys.mjs index 95da27b87eb3..c1b29e567533 100644 --- a/toolkit/components/extensions/ExtensionProcessScript.sys.mjs +++ b/toolkit/components/extensions/ExtensionProcessScript.sys.mjs @@ -159,7 +159,8 @@ ExtensionManager = { manifestVersion: extension.manifestVersion, extensionPageCSP: extension.extensionPageCSP, - + sandboxPageCSP: extension.sandboxPageCSP, + sandboxPages: extension.sandboxPages, localizeCallback, backgroundScripts, diff --git a/toolkit/components/extensions/Schemas.sys.mjs b/toolkit/components/extensions/Schemas.sys.mjs index 3b001f21a2d1..b163f555096e 100644 --- a/toolkit/components/extensions/Schemas.sys.mjs +++ b/toolkit/components/extensions/Schemas.sys.mjs @@ -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 = diff --git a/toolkit/components/extensions/WebExtensionPolicy.cpp b/toolkit/components/extensions/WebExtensionPolicy.cpp index 242acd3d4b33..5860d509d94e 100644 --- a/toolkit/components/extensions/WebExtensionPolicy.cpp +++ b/toolkit/components/extensions/WebExtensionPolicy.cpp @@ -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 aGlobs, - nsTArray>& aResult, ErrorResult& aRv) { + nsTArray>& aResult, ErrorResult& aRv, + bool aAllowQuestion = true) { for (auto& elem : aGlobs) { if (elem.IsMatchGlob()) { aResult.AppendElement(elem.GetAsMatchGlob()->Core()); } else { RefPtr 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 && diff --git a/toolkit/components/extensions/WebExtensionPolicy.h b/toolkit/components/extensions/WebExtensionPolicy.h index 5faa09e45e93..0615e3e306d3 100644 --- a/toolkit/components/extensions/WebExtensionPolicy.h +++ b/toolkit/components/extensions/WebExtensionPolicy.h @@ -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 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 AllowedOrigins() { return do_AddRef(mHostPermissions); } diff --git a/toolkit/components/extensions/schemas/manifest.json b/toolkit/components/extensions/schemas/manifest.json index 1534c9309d64..000e5fc67310 100644 --- a/toolkit/components/extensions/schemas/manifest.json +++ b/toolkit/components/extensions/schemas/manifest.json @@ -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, diff --git a/toolkit/components/extensions/test/mochitest/test_ext_executeScript_mozextension.html b/toolkit/components/extensions/test/mochitest/test_ext_executeScript_mozextension.html index 97d03856945e..41d024f562e4 100644 --- a/toolkit/components/extensions/test/mochitest/test_ext_executeScript_mozextension.html +++ b/toolkit/components/extensions/test/mochitest/test_ext_executeScript_mozextension.html @@ -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": `` + ); + }); + + 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": `x`, + }, + }); + 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": `x`, + }, + }); + 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": `x`, + }, + }); + 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 for exposing to a sandboxed document. + matches: [""], + }, + ]; + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest, + files: { + "sandbox.html": ` + + + `, + // Cannot use browser.test here due to bug 1896824 + "privileged.html": `