diff --git a/toolkit/components/extensions/Extension.jsm b/toolkit/components/extensions/Extension.jsm index 463d2da3a582..3b993ab68184 100644 --- a/toolkit/components/extensions/Extension.jsm +++ b/toolkit/components/extensions/Extension.jsm @@ -530,9 +530,10 @@ const manifestTypes = new Map([ * `loadManifest` has been called, and completed. */ class ExtensionData { - constructor(rootURI) { + constructor(rootURI, isPrivileged = false) { this.rootURI = rootURI; this.resourceURL = rootURI.spec; + this.isPrivileged = isPrivileged; this.manifest = null; this.type = null; @@ -553,6 +554,38 @@ class ExtensionData { this.eventPagesEnabled = eventPagesEnabled; } + /** + * A factory function that allows the construction of ExtensionData, with + * the isPrivileged flag computed asynchronously. + * + * @param {nsIURI} rootURI + * The URI pointing to the extension root. + * @param {function(type, id)} checkPrivileged + * An (async) function that takes the addon type and addon ID and returns + * whether the given add-on is privileged. + * @returns {ExtensionData} + */ + static async constructAsync({ rootURI, checkPrivileged }) { + let extension = new ExtensionData(rootURI); + // checkPrivileged depends on the extension type and id. + await extension.initializeAddonTypeAndID(); + let { type, id } = extension; + // Map the extension type to the type name used by the add-on manager. + // TODO bug 1757084: Remove this. + type = type == "langpack" ? "locale" : type; + extension.isPrivileged = await checkPrivileged(type, id); + return extension; + } + + static getIsPrivileged({ signedState, builtIn, temporarilyInstalled }) { + return ( + signedState === AddonManager.SIGNEDSTATE_PRIVILEGED || + signedState === AddonManager.SIGNEDSTATE_SYSTEM || + builtIn || + (AddonSettings.EXPERIMENTS_ENABLED && temporarilyInstalled) + ); + } + get builtinMessages() { return null; } @@ -725,25 +758,8 @@ class ExtensionData { }); } - canCheckSignature() { - // ExtensionData instances can't check the signature because it is not yet - // available when XPIProvider does use it to load the extension manifest. - // - // This method will return true for the ExtensionData subclasses (like - // the Extension class) to enable the additional validation that would require - // the signature to be available (e.g. to check if the extension is allowed to - // use a privileged permission). - return this.constructor != ExtensionData; - } - get restrictSchemes() { - // mozillaAddons permission is only allowed for privileged addons and - // filtered out if the extension isn't privileged. - // When the manifest is loaded by an explicit ExtensionData class - // instance, the signature data isn't available yet and this helper - // would always return false, but it will return true when appropriate - // (based on the isPrivileged boolean property) for the Extension class. - return !this.hasPermission("mozillaAddons"); + return !(this.isPrivileged && this.hasPermission("mozillaAddons")); } /** @@ -1027,15 +1043,48 @@ class ExtensionData { return Schemas.normalize(this.rawManifest, manifestType, context); } + async initializeAddonTypeAndID() { + if (this.type) { + // Already initialized. + return; + } + this.rawManifest = await this.readJSON("manifest.json"); + let manifest = this.rawManifest; + + if (manifest.theme) { + this.type = "theme"; + } else if (manifest.langpack_id) { + // TODO bug 1757084: This should be "locale". + this.type = "langpack"; + } else if (manifest.dictionaries) { + this.type = "dictionary"; + } else if (manifest.site_permissions) { + this.type = "sitepermission"; + } else { + this.type = "extension"; + } + + if (!this.id) { + let bss = + manifest.browser_specific_settings?.gecko || + manifest.applications?.gecko; + let id = bss?.id; + // This is a basic type check. + // When parseManifest is called, the ID is validated more thoroughly + // because the id is defined to be an ExtensionID type in + // toolkit/components/extensions/schemas/manifest.json + if (typeof id == "string") { + this.id = id; + } + } + } + // eslint-disable-next-line complexity async parseManifest() { - let [manifest] = await Promise.all([ - this.readJSON("manifest.json"), - Management.lazyInit(), - ]); + await Promise.all([this.initializeAddonTypeAndID(), Management.lazyInit()]); + let manifest = this.rawManifest; this.manifest = manifest; - this.rawManifest = manifest; if (manifest.default_locale) { await this.initLocale(); @@ -1054,18 +1103,6 @@ class ExtensionData { } } - if (this.manifest.theme) { - this.type = "theme"; - } else if (this.manifest.langpack_id) { - this.type = "langpack"; - } else if (this.manifest.dictionaries) { - this.type = "dictionary"; - } else if (this.manifest.site_permissions) { - this.type = "sitepermission"; - } else { - this.type = "extension"; - } - let normalized = await this._getNormalizedManifest(); if (normalized.error) { this.manifestError(normalized.error); @@ -1096,8 +1133,6 @@ class ExtensionData { this.logWarning("Event pages are not currently supported."); } - this.id ??= manifest.applications?.gecko?.id; - let apiNames = new Set(); let dependencies = new Set(); let originPermissions = new Set(); @@ -1106,6 +1141,9 @@ class ExtensionData { let schemaPromises = new Map(); + // Note: this.id and this.type were computed in initializeAddonTypeAndID. + // The format of `this.id` was confirmed to be a valid extensionID by the + // Schema validation as part of the _getNormalizedManifest() call. let result = { apiNames, dependencies, @@ -1148,18 +1186,6 @@ class ExtensionData { } else if (type.api) { apiNames.add(type.api); } else if (type.invalid) { - if (!this.canCheckSignature() && PRIVILEGED_PERMS.has(perm)) { - // Do not emit the warning if the invalid permission is a privileged one - // and the current instance can't yet check for a valid signature - // (see Bug 1675858 and the inline comment inside the canCheckSignature - // method for more details). - // - // This parseManifest method will be called again on the Extension class - // instance, which will have the signature available and the invalid - // extension permission warnings will be collected and logged if necessary. - continue; - } - this.manifestWarning(`Invalid extension permission: ${perm}`); continue; } @@ -2101,7 +2127,7 @@ let pendingExtensions = new Map(); */ class Extension extends ExtensionData { constructor(addonData, startupReason) { - super(addonData.resourceURI); + super(addonData.resourceURI, addonData.isPrivileged); this.startupStates = new Set(); this.state = "Not started"; @@ -2345,15 +2371,6 @@ class Extension extends ExtensionData { return [this.id, this.version, Services.locale.appLocaleAsBCP47]; } - get isPrivileged() { - return ( - this.addonData.signedState === AddonManager.SIGNEDSTATE_PRIVILEGED || - this.addonData.signedState === AddonManager.SIGNEDSTATE_SYSTEM || - this.addonData.builtIn || - (AddonSettings.EXPERIMENTS_ENABLED && this.temporarilyInstalled) - ); - } - get temporarilyInstalled() { return !!this.addonData.temporarilyInstalled; } diff --git a/toolkit/components/extensions/ExtensionTestCommon.jsm b/toolkit/components/extensions/ExtensionTestCommon.jsm index 8e565c1ef4e6..8f8b18711a8c 100644 --- a/toolkit/components/extensions/ExtensionTestCommon.jsm +++ b/toolkit/components/extensions/ExtensionTestCommon.jsm @@ -36,6 +36,11 @@ ChromeUtils.defineModuleGetter( "Extension", "resource://gre/modules/Extension.jsm" ); +ChromeUtils.defineModuleGetter( + this, + "ExtensionData", + "resource://gre/modules/Extension.jsm" +); ChromeUtils.defineModuleGetter( this, "ExtensionParent", @@ -575,6 +580,12 @@ ExtensionTestCommon = class ExtensionTestCommon { signedState = AddonManager.SIGNEDSTATE_SYSTEM; } + let isPrivileged = ExtensionData.getIsPrivileged({ + signedState, + builtIn: false, + temporarilyInstalled: !!data.temporarilyInstalled, + }); + return new Extension( { id, @@ -583,6 +594,7 @@ ExtensionTestCommon = class ExtensionTestCommon { signedState, incognitoOverride: data.incognitoOverride, temporarilyInstalled: !!data.temporarilyInstalled, + isPrivileged, TEST_NO_ADDON_MANAGER: true, // By default we set TEST_NO_DELAYED_STARTUP to true TEST_NO_DELAYED_STARTUP: !data.delayedStartup, diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js index 5eee8739812b..0a5d534fd312 100644 --- a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js @@ -668,26 +668,28 @@ add_task(async function update_unprivileged_with_mozillaAddons() { }); // Tests that invalid permission warning for privileged permissions requested -// without the privilged signature are emitted by the Extension class instance -// but not for the ExtensionData instances (on which the signature is not -// available and the warning would be emitted even for the ones signed correctly). +// are not emitted for privileged extensions, only for unprivileged extensions. add_task( async function test_invalid_permission_warning_on_privileged_permission() { await AddonTestUtils.promiseStartupManager(); + const MANIFEST_WARNINGS = [ + "Reading manifest: Invalid extension permission: mozillaAddons", + "Reading manifest: Invalid extension permission: resource://x/", + "Reading manifest: Invalid extension permission: about:reader*", + ]; + async function testInvalidPermissionWarning({ isPrivileged }) { let id = isPrivileged ? "privileged-addon@mochi.test" : "nonprivileged-addon@mochi.test"; - let expectedWarnings = isPrivileged - ? [] - : ["Reading manifest: Invalid extension permission: mozillaAddons"]; + let expectedWarnings = isPrivileged ? [] : MANIFEST_WARNINGS; const ext = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { - permissions: ["mozillaAddons"], + permissions: ["mozillaAddons", "resource://x/", "about:reader*"], applications: { gecko: { id } }, }, background() {}, @@ -711,28 +713,67 @@ add_task( // ExtensionData instance created below). let generatedExt = ExtensionTestCommon.generate({ manifest: { - permissions: ["mozillaAddons"], + permissions: ["mozillaAddons", "resource://x/", "about:reader*"], applications: { gecko: { id: "extension-data@mochi.test" } }, }, }); // Verify that XPIInstall.jsm will not collect the warning for the // privileged permission as expected. - const extData = new ExtensionData(generatedExt.rootURI); - await extData.loadManifest(); + async function getWarningsFromExtensionData({ isPrivileged }) { + let extData; + if (typeof isPrivileged == "function") { + // isPrivileged expected to be computed asynchronously. + extData = await ExtensionData.constructAsync({ + rootURI: generatedExt.rootURI, + checkPrivileged: isPrivileged, + }); + } else { + extData = new ExtensionData(generatedExt.rootURI, isPrivileged); + } + await extData.loadManifest(); + + // This assertion is just meant to prevent the test to pass if there were + // no warnings because some errors prevented the warnings to be + // collected). + Assert.deepEqual( + extData.errors, + [], + "No errors collected by the ExtensionData instance" + ); + return extData.warnings; + } + Assert.deepEqual( - extData.warnings, - [], - "No warnings for mozillaAddons permission collected for the ExtensionData instance" + await getWarningsFromExtensionData({ isPrivileged: undefined }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions by default" ); - // This assertion is just meant to prevent the test to pass if there were no warnings - // because some errors prevented the warnings to be collected). Assert.deepEqual( - extData.errors, - [], - "No errors collected by the ExtensionData instance" + await getWarningsFromExtensionData({ isPrivileged: false }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions for non-privileged extensions" ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: true }), + [], + "No warnings about privileged permissions on privileged extensions" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: async () => false }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions for non-privileged extensions (async)" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: async () => true }), + [], + "No warnings about privileged permissions on privileged extensions (async)" + ); + // Cleanup the generated xpi file. await generatedExt.cleanupGeneratedFile(); diff --git a/toolkit/mozapps/extensions/internal/XPIInstall.jsm b/toolkit/mozapps/extensions/internal/XPIInstall.jsm index 5093f40ae266..b91ecfe1c91f 100644 --- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm +++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm @@ -441,14 +441,30 @@ function waitForAllPromises(promises) { * * @param {Package} aPackage * The install package for the add-on - * @returns {AddonInternal} + * @param {XPIStateLocation} aLocation + * The install location the add-on is installed in, or will be + * installed to. + * @returns {{ addon: AddonInternal, verifiedSignedState: object}} * @throws if the install manifest in the stream is corrupt or could not * be read */ -async function loadManifestFromWebManifest(aPackage) { - let extension = new ExtensionData( - XPIInternal.maybeResolveURI(aPackage.rootURI) - ); +async function loadManifestFromWebManifest(aPackage, aLocation) { + let verifiedSignedState; + let extension = await ExtensionData.constructAsync({ + rootURI: XPIInternal.maybeResolveURI(aPackage.rootURI), + async checkPrivileged(type, id) { + verifiedSignedState = await aPackage.verifySignedState( + id, + type, + aLocation + ); + return ExtensionData.getIsPrivileged({ + signedState: verifiedSignedState.signedState, + builtIn: aLocation.isBuiltin, + temporarilyInstalled: aLocation.isTemporary, + }); + }, + }); let manifest = await extension.loadManifest(); @@ -574,7 +590,7 @@ async function loadManifestFromWebManifest(aPackage) { addon.softDisabled = addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED; - return addon; + return { addon, verifiedSignedState }; } async function readRecommendationStates(aPackage, aAddonID) { @@ -649,13 +665,23 @@ function generateTemporaryInstallID(aFile) { var loadManifest = async function(aPackage, aLocation, aOldAddon) { let addon; + let verifiedSignedState; if (await aPackage.hasResource("manifest.json")) { - addon = await loadManifestFromWebManifest(aPackage); + ({ addon, verifiedSignedState } = await loadManifestFromWebManifest( + aPackage, + aLocation + )); } else { + // TODO bug 1674799: Remove this unused branch. for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) { if (await aPackage.hasResource(loader.manifestFile)) { addon = await loader.loadManifest(aPackage); addon.loader = loader.name; + verifiedSignedState = await aPackage.verifySignedState( + addon.id, + addon.type, + aLocation + ); break; } } @@ -671,11 +697,7 @@ var loadManifest = async function(aPackage, aLocation, aOldAddon) { addon.rootURI = aPackage.rootURI.spec; addon.location = aLocation; - let { signedState, cert } = await aPackage.verifySignedState( - addon.id, - addon.type, - aLocation - ); + let { signedState, cert } = verifiedSignedState; addon.signedState = signedState; addon.signedDate = cert?.validity?.notBefore / 1000 || null; if (!addon.isPrivileged) { diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.jsm b/toolkit/mozapps/extensions/internal/XPIProvider.jsm index 4dbc7875638f..b1621831d065 100644 --- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm +++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm @@ -33,6 +33,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm", Dictionary: "resource://gre/modules/Extension.jsm", Extension: "resource://gre/modules/Extension.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", Langpack: "resource://gre/modules/Extension.jsm", SitePermission: "resource://gre/modules/Extension.jsm", FileUtils: "resource://gre/modules/FileUtils.jsm", @@ -1804,6 +1805,13 @@ class BootstrapScope { } } } + // TODO D128233: Replace AddonInternal's isPrivileged getter with a call to + // Extensions.getIsPrivileged, and use addon.isPrivileged instead of this. + const isPrivileged = ExtensionData.getIsPrivileged({ + signedState: addon.signedState, + builtIn: addon.location.isBuiltin, + temporarilyInstalled: addon.location.isTemporary, + }); let params = { id: addon.id, @@ -1813,6 +1821,7 @@ class BootstrapScope { temporarilyInstalled: addon.location.isTemporary, builtIn: addon.location.isBuiltin, isSystem: addon.location.isSystem, + isPrivileged, recommendationState: addon.recommendationState, }; diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js new file mode 100644 index 000000000000..fd32dc68f099 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { XPIInstall } = ChromeUtils.import( + "resource://gre/modules/addons/XPIInstall.jsm" +); +const { + XPIInternal: { + BuiltInLocation, + KEY_APP_PROFILE, + KEY_APP_SYSTEM_DEFAULTS, + KEY_APP_SYSTEM_PROFILE, + TemporaryInstallLocation, + XPIStates, + }, +} = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm"); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +Services.prefs.setIntPref( + "extensions.enabledScopes", + // SCOPE_PROFILE is enabled by default, + // SCOPE_APPLICATION is to enable KEY_APP_SYSTEM_PROFILE, which we need to + // test the combination (isSystem && !isBuiltin) in test_system_location. + AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION +); +// test_builtin_system_location tests the (isSystem && isBuiltin) combination +// (i.e. KEY_APP_SYSTEM_DEFAULTS). That location only exists if this directory +// is found: +const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"], true); +registerDirectory("XREAppFeat", distroDir); + +function getInstallLocation({ + isBuiltin = false, + isSystem = false, + isTemporary = false, +}) { + if (isTemporary) { + // Temporary installation. Signatures will not be verified. + return TemporaryInstallLocation; // KEY_APP_TEMPORARY + } + let location; + if (isSystem) { + if (isBuiltin) { + // System location. Signatures will not be verified. + location = XPIStates.getLocation(KEY_APP_SYSTEM_DEFAULTS); + } else { + // Normandy installations. Signatures will be verified. + location = XPIStates.getLocation(KEY_APP_SYSTEM_PROFILE); + } + } else if (isBuiltin) { + // Packaged with the application. Signatures will not be verified. + location = BuiltInLocation; // KEY_APP_BUILTINS + } else { + // By default - The profile directory. Signatures will be verified. + location = XPIStates.getLocation(KEY_APP_PROFILE); + } + // Sanity checks to make sure that the flags match the expected values. + if (location.isSystem !== isSystem) { + ok(false, `${location.name}, unexpected isSystem=${location.isSystem}`); + } + if (location.isBuiltin !== isBuiltin) { + ok(false, `${location.name}, unexpected isBuiltin=${location.isBuiltin}`); + } + return location; +} + +async function testLoadManifest({ location, expectPrivileged }) { + location ??= getInstallLocation({}); + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: { + applications: { gecko: { id: "@with-privileged-perm" } }, + permissions: ["mozillaAddons", "cookies"], + }, + }); + let actualPermissions; + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + let addon = await XPIInstall.loadManifestFromFile(xpi, location); + actualPermissions = addon.userPermissions; + }); + if (expectPrivileged) { + AddonTestUtils.checkMessages(messages, { + expected: [], + forbidden: [ + { + message: /Reading manifest: Invalid extension permission/, + }, + ], + }); + Assert.deepEqual( + actualPermissions, + { origins: [], permissions: ["mozillaAddons", "cookies"] }, + "Privileged permission should exist" + ); + } else { + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Reading manifest: Invalid extension permission: mozillaAddons/, + }, + ], + forbidden: [], + }); + Assert.deepEqual( + actualPermissions, + { origins: [], permissions: ["cookies"] }, + "Privileged permission should be ignored" + ); + } +} + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_regular_addon() { + AddonTestUtils.usePrivilegedSignatures = false; + await testLoadManifest({ + expectPrivileged: false, + }); +}); + +add_task(async function test_privileged_signature() { + AddonTestUtils.usePrivilegedSignatures = true; + await testLoadManifest({ + expectPrivileged: true, + }); +}); + +add_task(async function test_system_signature() { + AddonTestUtils.usePrivilegedSignatures = "system"; + await testLoadManifest({ + expectPrivileged: true, + }); +}); + +add_task(async function test_builtin_location() { + AddonTestUtils.usePrivilegedSignatures = false; + await testLoadManifest({ + expectPrivileged: true, + location: getInstallLocation({ isBuiltin: true }), + }); +}); + +add_task(async function test_system_location() { + AddonTestUtils.usePrivilegedSignatures = false; + await testLoadManifest({ + expectPrivileged: false, + location: getInstallLocation({ isSystem: true }), + }); +}); + +add_task(async function test_builtin_system_location() { + AddonTestUtils.usePrivilegedSignatures = false; + await testLoadManifest({ + expectPrivileged: true, + location: getInstallLocation({ isSystem: true, isBuiltin: true }), + }); +}); + +add_task(async function test_temporary_regular() { + AddonTestUtils.usePrivilegedSignatures = false; + Services.prefs.setBoolPref("extensions.experiments.enabled", false); + await testLoadManifest({ + expectPrivileged: false, + location: getInstallLocation({ isTemporary: true }), + }); +}); + +add_task(async function test_temporary_privileged_signature() { + AddonTestUtils.usePrivilegedSignatures = true; + Services.prefs.setBoolPref("extensions.experiments.enabled", false); + await testLoadManifest({ + expectPrivileged: true, + location: getInstallLocation({ isTemporary: true }), + }); +}); + +add_task(async function test_temporary_experiments_enabled() { + AddonTestUtils.usePrivilegedSignatures = false; + Services.prefs.setBoolPref("extensions.experiments.enabled", true); + await testLoadManifest({ + expectPrivileged: true, + location: getInstallLocation({ isTemporary: true }), + }); +}); diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini index e56e953a3d8d..36bb528150ac 100644 --- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini +++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini @@ -57,6 +57,7 @@ skip-if = appname != "firefox" || (os == "win" && processor == "aarch64") # bug [test_installtrigger_schemes.js] [test_isDebuggable.js] [test_isReady.js] +[test_loadManifest_isPrivileged.js] [test_locale.js] [test_moved_extension_metadata.js] skip-if = true