src/parsers/manifestjson.js (1,038 lines of code) (raw):
/* eslint-disable import/namespace */
import path from 'path';
import RJSON from '@fregante/relaxed-json';
import { oneLine } from 'common-tags';
import getImageSize from 'image-size';
import upath from 'upath';
import bcd from '@mdn/browser-compat-data';
import { mozCompare } from 'addons-moz-compare';
import { getDefaultConfigValue } from 'yargs-options';
import {
validateAddon,
validateDictionary,
validateLangPack,
validateStaticTheme,
} from 'schema/validator';
import {
CSP_KEYWORD_RE,
DEPRECATED_MANIFEST_PROPERTIES,
FILE_EXTENSIONS_TO_MIME,
IMAGE_FILE_EXTENSIONS,
INSTALL_ORIGINS_DATAPATH_REGEX,
MANIFEST_JSON,
MESSAGES_JSON,
LOCALES_DIRECTORY,
PACKAGE_EXTENSION,
PERMS_DATAPATH_REGEX,
RESTRICTED_HOMEPAGE_URLS,
RESTRICTED_PERMISSIONS,
SCHEMA_KEYWORDS,
STATIC_THEME_IMAGE_MIMES,
} from 'const';
import log from 'logger';
import * as messages from 'messages';
import JSONParser from 'parsers/json';
import {
androidStrictMinVersion,
basicCompatVersionComparison,
firefoxStrictMinVersion,
firstStableVersion,
isToolkitVersionString,
isValidVersionString,
normalizePath,
parseCspPolicy,
} from 'utils';
import BLOCKED_CONTENT_SCRIPT_HOSTS from 'blocked_content_script_hosts.txt';
async function getStreamImageSize(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
try {
return getImageSize(Buffer.concat(chunks));
} catch (error) {
/* The size information isn't available yet */
}
}
return getImageSize(Buffer.concat(chunks));
}
async function getImageMetadata(io, iconPath) {
// Get a non-utf8 input stream by setting encoding to null.
let encoding = null;
if (iconPath.endsWith('.svg')) {
encoding = 'utf-8';
}
const fileStream = await io.getFileAsStream(iconPath, { encoding });
const data = await getStreamImageSize(fileStream);
return {
width: data.width,
height: data.height,
mime: FILE_EXTENSIONS_TO_MIME[data.type],
};
}
function getNormalizedExtension(_path) {
return path.extname(_path).substring(1).toLowerCase();
}
export default class ManifestJSONParser extends JSONParser {
constructor(
jsonString,
collector,
{
filename = MANIFEST_JSON,
RelaxedJSON = RJSON,
selfHosted = getDefaultConfigValue('self-hosted'),
schemaValidatorOptions,
io = null,
isAlreadySigned = false,
isEnterprise = getDefaultConfigValue('enterprise'),
restrictedPermissions = RESTRICTED_PERMISSIONS,
} = {}
) {
super(jsonString, collector, { filename });
this.parse(RelaxedJSON);
// Set up some defaults in case parsing fails.
if (typeof this.parsedJSON === 'undefined' || this.isValid === false) {
this.parsedJSON = {
manifest_version: null,
name: null,
type: PACKAGE_EXTENSION,
version: null,
};
} else {
// We've parsed the JSON; now we can validate the manifest.
// --enterprise implies --self-hosted since we cannot host enterprise
// add-ons on AMO.
this.selfHosted = selfHosted || isEnterprise;
this.schemaValidatorOptions = schemaValidatorOptions;
const hasManifestKey = (key) =>
Object.prototype.hasOwnProperty.call(this.parsedJSON, key);
this.isStaticTheme = false;
this.isLanguagePack = false;
this.isDictionary = false;
// Keep the addon type detection in sync with the most updated logic
// used on the Firefox side, as defined in ExtensionData parseManifest
// method.
if (hasManifestKey('theme')) {
this.isStaticTheme = true;
} else if (hasManifestKey('langpack_id')) {
this.isLanguagePack = true;
} else if (hasManifestKey('dictionaries')) {
this.isDictionary = true;
}
this.io = io;
this.isAlreadySigned = isAlreadySigned;
this.isEnterpriseAddon = isEnterprise;
this.isPrivilegedAddon = this.schemaValidatorOptions?.privileged ?? false;
this.restrictedPermissions = restrictedPermissions;
this._validate();
}
}
checkKeySupport(
support,
minFirefoxVersion,
minAndroidVersion,
key,
isPermission = false
) {
if (support.firefox && minFirefoxVersion) {
// We don't have to support gaps in the `@mdn/browser-compat-data`
// information for Firefox Desktop so far.
const versionAdded = support.firefox.version_added;
if (basicCompatVersionComparison(versionAdded, minFirefoxVersion)) {
if (!isPermission) {
this.collector.addWarning(
messages.keyFirefoxUnsupportedByMinVersion(
key,
minFirefoxVersion,
versionAdded
)
);
} else {
this.collector.addNotice(
messages.permissionFirefoxUnsupportedByMinVersion(
key,
minFirefoxVersion,
versionAdded
)
);
}
}
}
if (support.firefox_android && minAndroidVersion) {
// `@mdn/browser-compat-data` sometimes provides data with gaps, e.g., a
// feature was supported in Fennec (added in 56 and removed in 79) and
// then re-added in Fenix (added in 85) and this is expressed with an
// array of objects instead of a single object.
//
// This is the case of the `permissions.browsingData` on Android for
// instance and we decided to only warn the developer if the minVersion
// required by the extension is not greater or equal of the first version
// where the feature was officially supported for the first time (and do
// not warn if the minVersion is in one of the few version gaps).
const versionAddedAndroid = firstStableVersion(support.firefox_android);
if (
basicCompatVersionComparison(versionAddedAndroid, minAndroidVersion)
) {
if (!isPermission) {
this.collector.addWarning(
messages.keyFirefoxAndroidUnsupportedByMinVersion(
key,
minAndroidVersion,
versionAddedAndroid
)
);
} else {
this.collector.addNotice(
messages.permissionFirefoxAndroidUnsupportedByMinVersion(
key,
minAndroidVersion,
versionAddedAndroid
)
);
}
}
}
}
checkCompatInfo(
compatInfo,
minFirefoxVersion,
minAndroidVersion,
key,
manifestKeyValue
) {
for (const subkey in compatInfo) {
if (Object.prototype.hasOwnProperty.call(compatInfo, subkey)) {
const subkeyInfo = compatInfo[subkey];
if (subkey === '__compat') {
this.checkKeySupport(
subkeyInfo.support,
minFirefoxVersion,
minAndroidVersion,
key
);
} else if (
typeof manifestKeyValue === 'object' &&
manifestKeyValue !== null &&
Object.prototype.hasOwnProperty.call(manifestKeyValue, subkey)
) {
this.checkCompatInfo(
subkeyInfo,
minFirefoxVersion,
minAndroidVersion,
`${key}.${subkey}`,
manifestKeyValue[subkey]
);
} else if (
(key === 'permissions' || key === 'optional_permissions') &&
manifestKeyValue.includes(subkey)
) {
this.checkKeySupport(
subkeyInfo.__compat.support,
minFirefoxVersion,
minAndroidVersion,
`${key}:${subkey}`,
true
);
}
}
}
}
errorLookup(error) {
// This is the default message.
let baseObject = messages.JSON_INVALID;
// This is the default from webextension-manifest-schema, but it's not a
// super helpful error. We'll tidy it up a bit:
if (error && error.message) {
const lowerCaseMessage = error.message.toLowerCase();
if (lowerCaseMessage === 'must match a schema in anyof') {
// eslint-disable-next-line no-param-reassign
error.message = 'is not a valid key or has invalid extra properties';
}
}
const overrides = {
message: `"${error.instancePath || '/'}" ${error.message}`,
instancePath: error.instancePath,
};
if (error.keyword === SCHEMA_KEYWORDS.REQUIRED) {
baseObject = messages.MANIFEST_FIELD_REQUIRED;
} else if (error.keyword === SCHEMA_KEYWORDS.DEPRECATED) {
if (
Object.prototype.hasOwnProperty.call(
DEPRECATED_MANIFEST_PROPERTIES,
error.instancePath
)
) {
baseObject =
messages[DEPRECATED_MANIFEST_PROPERTIES[error.instancePath]];
if (baseObject === null) {
baseObject = messages.MANIFEST_FIELD_DEPRECATED;
}
let errorDescription = baseObject.description;
if (errorDescription === null) {
errorDescription = error.message;
}
// Set the description to the actual message from the schema
overrides.message = baseObject.message;
overrides.description = errorDescription;
}
// TODO(#2462): add a messages.MANIFEST_FIELD_DEPRECATED and ensure that deprecated
// properties are handled properly (e.g. we should also detect when the deprecated
// keyword is actually used to warn the developer of additional properties not
// explicitly defined in the schemas).
} else if (
error.keyword === SCHEMA_KEYWORDS.MIN_MANIFEST_VERSION ||
error.keyword === SCHEMA_KEYWORDS.MAX_MANIFEST_VERSION
) {
// Choose a different message for permissions unsupported with the
// add-on manifest_version.
if (PERMS_DATAPATH_REGEX.test(error.instancePath)) {
baseObject = messages.manifestPermissionUnsupported(error.data, error);
} else if (error.instancePath === '/applications') {
baseObject = messages.APPLICATIONS_INVALID;
} else {
baseObject = messages.manifestFieldUnsupported(
error.instancePath,
error
);
}
// Set the message and description from the one generated by the
// choosen message.
overrides.message = baseObject.message;
overrides.description = baseObject.description;
} else if (
error.instancePath.startsWith('/permissions') &&
error.keyword === SCHEMA_KEYWORDS.VALIDATE_PRIVILEGED_PERMISSIONS &&
error.params.privilegedPermissions
) {
if (this.isPrivilegedAddon) {
baseObject = error.params.privilegedPermissions.length
? messages.mozillaAddonsPermissionRequired(error)
: messages.privilegedFeaturesRequired(error);
} else {
baseObject = messages.manifestPermissionsPrivileged(error);
}
overrides.message = baseObject.message;
overrides.description = baseObject.description;
} else if (
error.instancePath.startsWith('/permissions') &&
typeof error.data !== 'undefined' &&
typeof error.data !== 'string'
) {
baseObject = messages.MANIFEST_BAD_PERMISSION;
overrides.message = `Permissions ${error.message}.`;
} else if (
error.instancePath.startsWith('/optional_permissions') &&
typeof error.data !== 'undefined' &&
typeof error.data !== 'string'
) {
baseObject = messages.MANIFEST_BAD_OPTIONAL_PERMISSION;
overrides.message = `Permissions ${error.message}.`;
} else if (
error.instancePath.startsWith('/host_permissions') &&
typeof error.data !== 'undefined' &&
typeof error.data !== 'string'
) {
baseObject = messages.MANIFEST_BAD_HOST_PERMISSION;
overrides.message = `Permissions ${error.message}.`;
} else if (error.keyword === SCHEMA_KEYWORDS.TYPE) {
baseObject = messages.MANIFEST_FIELD_INVALID;
} else if (error.keyword === SCHEMA_KEYWORDS.PRIVILEGED) {
baseObject = this.isPrivilegedAddon
? messages.mozillaAddonsPermissionRequired(error)
: messages.manifestFieldPrivileged(error);
overrides.message = baseObject.message;
overrides.description = baseObject.description;
}
// Arrays can be extremely verbose, this tries to make them a little
// more sane. Using a regex because there will likely be more as we
// expand the schema.
// Note that this works because the 2 regexps use similar patterns. We'll
// want to adjust this if they start to differ.
const match =
error.instancePath.match(PERMS_DATAPATH_REGEX) ||
error.instancePath.match(INSTALL_ORIGINS_DATAPATH_REGEX);
if (
match &&
baseObject.code !== messages.MANIFEST_BAD_PERMISSION.code &&
baseObject.code !== messages.MANIFEST_BAD_OPTIONAL_PERMISSION.code &&
baseObject.code !== messages.MANIFEST_BAD_HOST_PERMISSION.code &&
baseObject.code !== messages.MANIFEST_PERMISSION_UNSUPPORTED
) {
baseObject = messages[`MANIFEST_${match[1].toUpperCase()}`];
overrides.message = oneLine`/${match[1]}: Invalid ${match[1]}
"${error.data}" at ${match[2]}.`;
}
// Make sure we filter out warnings and errors code that should never be reported
// on manifest version 2 extensions.
const ignoredOnMV2 = [
messages.MANIFEST_HOST_PERMISSIONS.code,
messages.MANIFEST_BAD_HOST_PERMISSION.code,
];
if (
this.parsedJSON.manifest_version === 2 &&
ignoredOnMV2.includes(baseObject.code)
) {
return null;
}
return { ...baseObject, ...overrides };
}
_validate() {
// Not all messages returned by the schema are fatal to Firefox, messages
// that are just warnings should be added to this array.
const warnings = [
messages.MANIFEST_PERMISSIONS.code,
messages.MANIFEST_OPTIONAL_PERMISSIONS.code,
messages.MANIFEST_HOST_PERMISSIONS.code,
messages.MANIFEST_PERMISSION_UNSUPPORTED,
messages.MANIFEST_FIELD_UNSUPPORTED,
];
// Message with the following codes will be:
//
// - omitted if the add-on is being explicitly validated as privileged
// when the `--privileged` cli option was passed or `privileged` is
// set to true in the linter config. This is the case for privileged
// extensions in the https://github.com/mozilla-extensions/ org.
//
// - reported as warnings if the add-on is already signed
// (because it is expected for addons signed as privileged to be
// submitted to AMO to become listed, and so the warning is meant to
// be just informative and to let extension developers and reviewers
// to know that the extension is expected to be signed as privileged
// or it wouldn't work).
//
// - reported as errors if the add-on isn't signed, which should
// reject the submission of a privileged extension on AMO (and
// have it signed with a non privileged certificate by mistake).
const privilegedManifestMessages = [
messages.MANIFEST_PERMISSIONS_PRIVILEGED,
messages.MANIFEST_FIELD_PRIVILEGED,
];
if (this.isAlreadySigned) {
warnings.push(...privilegedManifestMessages);
}
let validate = validateAddon;
if (this.isStaticTheme) {
validate = validateStaticTheme;
} else if (this.isLanguagePack) {
validate = validateLangPack;
} else if (this.isDictionary) {
validate = validateDictionary;
}
this.isValid = validate(this.parsedJSON, this.schemaValidatorOptions);
if (!this.isValid) {
log.debug(
'Schema Validation messages',
JSON.stringify(validate.errors, null, 2)
);
validate.errors.forEach((error) => {
const message = this.errorLookup(error);
// errorLookup call returned a null or undefined message,
// and so the error should be ignored.
if (!message) {
return;
}
if (warnings.includes(message.code)) {
this.collector.addWarning(message);
} else {
this.collector.addError(message);
}
// Add-ons with bad permissions will fail to install in Firefox, so
// we consider them invalid.
if (message.code === messages.MANIFEST_BAD_PERMISSION.code) {
this.isValid = false;
}
});
}
if (this.parsedJSON.applications?.gecko_android) {
this.collector.addError(
messages.manifestFieldUnsupported('/applications/gecko_android')
);
this.isValid = false;
}
if (this.parsedJSON.manifest_version < 3) {
if (
this.parsedJSON.browser_specific_settings?.gecko &&
this.parsedJSON.applications
) {
this.collector.addWarning(messages.IGNORED_APPLICATIONS_PROPERTY);
} else if (this.parsedJSON.applications) {
this.collector.addWarning(messages.APPLICATIONS_DEPRECATED);
}
}
if (
this.parsedJSON.browser_specific_settings &&
(this.parsedJSON.browser_specific_settings.gecko ||
this.parsedJSON.browser_specific_settings.gecko_android)
) {
this.parsedJSON.applications = {
...(this.parsedJSON.applications || {}),
...this.parsedJSON.browser_specific_settings,
};
}
// We only want `admin_install_only` to be set in `bss` when `--enterprise`
// is set, otherwise we don't want the flag _at all_, which includes both
// `bss` and `applications`.
if (this.isEnterpriseAddon) {
if (
this.parsedJSON.browser_specific_settings?.gecko?.admin_install_only !==
true
) {
this.collector.addError(messages.ADMIN_INSTALL_ONLY_REQUIRED);
this.isValid = false;
}
} else if (
typeof this.parsedJSON.applications?.gecko?.admin_install_only !==
'undefined'
) {
this.collector.addError(messages.ADMIN_INSTALL_ONLY_PROP_RESERVED);
this.isValid = false;
}
if (
this.parsedJSON.browser_specific_settings?.gecko
?.data_collection_permissions
) {
this.collector.addError(
messages.DATA_COLLECTION_PERMISSIONS_PROP_RESERVED
);
this.isValid = false;
}
if (this.parsedJSON.content_security_policy != null) {
this.validateCspPolicy(this.parsedJSON.content_security_policy);
}
if (this.parsedJSON.update_url) {
this.collector.addNotice(messages.MANIFEST_UNUSED_UPDATE);
}
if (this.parsedJSON.granted_host_permissions) {
this.collector.addWarning(
messages.manifestFieldPrivilegedOnly('granted_host_permissions')
);
}
if (this.parsedJSON.background) {
const hasScripts = Array.isArray(this.parsedJSON.background.scripts);
if (hasScripts) {
this.parsedJSON.background.scripts.forEach((script) => {
this.validateFileExistsInPackage(script, 'script');
});
}
const hasPage = !!this.parsedJSON.background.page;
if (hasPage) {
this.validateFileExistsInPackage(
this.parsedJSON.background.page,
'page'
);
}
if (this.parsedJSON.background.service_worker) {
if (!this.schemaValidatorOptions?.enableBackgroundServiceWorker) {
// Report an error and mark the manifest as invalid if background
// service worker support isn't enabled by the addons-linter feature
// flag.
if (hasScripts || hasPage) {
this.collector.addWarning(
messages.manifestFieldUnsupported('/background/service_worker')
);
} else {
this.collector.addError(
messages.manifestFieldUnsupported('/background/service_worker')
);
this.isValid = false;
}
} else if (this.parsedJSON.manifest_version >= 3) {
this.validateFileExistsInPackage(
this.parsedJSON.background.service_worker,
'script'
);
}
}
}
if (
this.parsedJSON.content_scripts &&
this.parsedJSON.content_scripts.length
) {
this.parsedJSON.content_scripts.forEach((scriptRule) => {
if (scriptRule.matches && scriptRule.matches.length) {
// Since `include_globs` only get's checked for patterns that are in
// `matches` we only need to validate `matches`
scriptRule.matches.forEach((matchPattern) => {
this.validateContentScriptMatchPattern(matchPattern);
});
}
if (scriptRule.js && scriptRule.js.length) {
scriptRule.js.forEach((script) => {
this.validateFileExistsInPackage(
script,
'script',
messages.manifestContentScriptFileMissing
);
});
}
if (scriptRule.css && scriptRule.css.length) {
scriptRule.css.forEach((style) => {
this.validateFileExistsInPackage(
style,
'css',
messages.manifestContentScriptFileMissing
);
});
}
});
}
if (this.parsedJSON.dictionaries) {
if (!this.getAddonId()) {
this.collector.addError(messages.MANIFEST_DICT_MISSING_ID);
this.isValid = false;
}
const numberOfDictionaries = Object.keys(
this.parsedJSON.dictionaries
).length;
if (numberOfDictionaries < 1) {
this.collector.addError(messages.MANIFEST_EMPTY_DICTS);
this.isValid = false;
} else if (numberOfDictionaries > 1) {
this.collector.addError(messages.MANIFEST_MULTIPLE_DICTS);
this.isValid = false;
}
Object.keys(this.parsedJSON.dictionaries).forEach((locale) => {
const filepath = this.parsedJSON.dictionaries[locale];
this.validateFileExistsInPackage(
filepath,
'binary',
messages.manifestDictionaryFileMissing
);
// A corresponding .aff file should exist for every .dic.
this.validateFileExistsInPackage(
filepath.replace(/\.dic$/, '.aff'),
'binary',
messages.manifestDictionaryFileMissing
);
});
}
if (
(!this.selfHosted || this.isEnterpriseAddon) &&
this.parsedJSON.applications?.gecko?.update_url
) {
if (this.isPrivilegedAddon) {
// We cannot know whether a privileged add-on will be listed or
// unlisted so we only emit a warning for MANIFEST_UPDATE_URL (not an
// error).
this.collector.addWarning(messages.MANIFEST_UPDATE_URL);
} else {
this.collector.addError(messages.MANIFEST_UPDATE_URL);
this.isValid = false;
}
}
if (
!this.isLanguagePack &&
(this.parsedJSON.applications?.gecko?.strict_max_version ||
this.parsedJSON.applications?.gecko_android?.strict_max_version)
) {
if (this.isDictionary) {
// Dictionaries should not have a strict_max_version at all.
this.isValid = false;
this.collector.addError(messages.STRICT_MAX_VERSION);
} else {
// Rest of the extensions can, even though it's not recommended.
this.collector.addNotice(messages.STRICT_MAX_VERSION);
}
}
const minFirefoxVersion = firefoxStrictMinVersion(this.parsedJSON);
const minAndroidVersion = androidStrictMinVersion(this.parsedJSON);
if (
!this.isLanguagePack &&
!this.isDictionary &&
(minFirefoxVersion || minAndroidVersion)
) {
for (const key in bcd.webextensions.manifest) {
if (Object.prototype.hasOwnProperty.call(this.parsedJSON, key)) {
const compatInfo = bcd.webextensions.manifest[key];
this.checkCompatInfo(
compatInfo,
minFirefoxVersion,
minAndroidVersion,
key,
this.parsedJSON[key]
);
}
}
}
this.validateName();
this.validateVersionString();
if (this.parsedJSON.default_locale) {
const msg = path.join(
LOCALES_DIRECTORY,
this.parsedJSON.default_locale,
'messages.json'
);
// Convert filename to unix path separator before
// searching it into the scanned files map.
if (!this.io.files[upath.toUnix(msg)]) {
this.collector.addError(messages.NO_MESSAGES_FILE);
this.isValid = false;
}
}
if (this?.io?.files) {
const fileList = Object.keys(this.io.files);
const localeDirRe = new RegExp(`^${LOCALES_DIRECTORY}/(.*?)/`);
const localeFileRe = new RegExp(
`^${LOCALES_DIRECTORY}/.*?/${MESSAGES_JSON}$`
);
const locales = [];
const localesWithMessagesJson = [];
const errors = [];
// Collect distinct locales (based on the content of `_locales/`) as
// well as the locales for which we have a `messages.json` file.
for (let i = 0; i < fileList.length; i++) {
const matches = fileList[i].match(localeDirRe);
if (matches && !locales.includes(matches[1])) {
locales.push(matches[1]);
}
if (matches && fileList[i].match(localeFileRe)) {
localesWithMessagesJson.push(matches[1]);
}
}
// Emit an error for each locale without a `messages.json` file.
for (let i = 0; i < locales.length; i++) {
if (!localesWithMessagesJson.includes(locales[i])) {
errors.push(
messages.noMessagesFileInLocales(
path.join(LOCALES_DIRECTORY, locales[i])
)
);
}
}
// When there is no default locale, we do not want to emit errors for
// missing locale files because we ignore those files.
if (!this.parsedJSON.default_locale) {
if (localesWithMessagesJson.length) {
this.collector.addError(messages.NO_DEFAULT_LOCALE);
this.isValid = false;
}
} else if (errors.length > 0) {
for (const error of errors) {
this.collector.addError(error);
}
this.isValid = false;
}
}
if (this.parsedJSON.developer) {
const { name, url } = this.parsedJSON.developer;
if (name) {
this.parsedJSON.author = name;
}
if (url) {
this.parsedJSON.homepage_url = url;
}
}
if (this.parsedJSON.homepage_url) {
this.validateHomePageURL(this.parsedJSON.homepage_url);
}
this.validateRestrictedPermissions();
this.validateExtensionID();
this.validateHiddenAddon();
this.validateDeprecatedBrowserStyle();
this.validateIncognito();
}
/**
* This method validates the manifest's name property in addition to the
* basic json schema validation. The name should not contain unnecessary
* whitespaces.
*/
validateName() {
const { name } = this.parsedJSON;
// The JSON schema validation already emits an error for non-string values.
if (typeof name !== 'string') {
return;
}
// We are relying on the `trim` function to remove the whitespaces but it
// doesn't cover all possible whitespace-like chars. See:
// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/trim
const trimmedName = name.trim();
if (trimmedName !== name || trimmedName.length < 2) {
this.collector.addError(messages.PROP_NAME_INVALID);
this.isValid = false;
}
}
/**
* This method determines whether the value of the `version` manifest key is
* valid for both AMO and Firefox, and strictness is a bit different depending
* on the manifest version.
*
* For MV3+: we enforce the following format: the value must be a string that
* has between 1 and 4 numbers, separated with dots. Each number must have up
* to 9 digits and leading zeros are not allowed.
*
* For MV2 only: if the value matches the toolkit version, we emit a warning.
* Otherwise, we enforce the same format as defined above for MV3 and above.
*/
validateVersionString() {
const { version } = this.parsedJSON;
if (isValidVersionString(version)) {
return;
}
if (
this.parsedJSON.manifest_version < 3 &&
isToolkitVersionString(version)
) {
this.collector.addWarning(messages.VERSION_FORMAT_DEPRECATED);
} else {
this.collector.addError(messages.VERSION_FORMAT_INVALID);
this.isValid = false;
}
}
validateHiddenAddon() {
// Only privileged add-ons can use the `hidden` manifest property.
if (!this.isPrivilegedAddon) {
return;
}
if (
this.parsedJSON.hidden &&
('action' in this.parsedJSON ||
'browser_action' in this.parsedJSON ||
// Note: When this was introduced, it was stricter than the Firefox
// side because Firefox didn't restrict `page_action` in Bug 1781998.
'page_action' in this.parsedJSON)
) {
this.collector.addError(messages.HIDDEN_NO_ACTION);
this.isValid = false;
}
}
validateDeprecatedBrowserStyle() {
if (this.parsedJSON.manifest_version !== 3) {
// The deprecation only affects MV2 -> MV3.
return;
}
const checkBrowserStyleInManifestKey = (manifestKey) => {
// Warn about `browser_style:true` because it is not compatible with
// "future" Firefox versions (Firefox 118+). We don't warn about
// `browser_style:false` because it is equivalent to not setting the
// property. Furthermore, setting it to false ensures a consistent
// appearance of MV3 extensions in Firefox 114 and earlier, because the
// default of options_ui.browser_style and sidebar_action.browser_style
// changed from true to false in Firefox 115.
if (this.parsedJSON[manifestKey]?.browser_style) {
const instancePath = `/${manifestKey}/browser_style`;
// Minimal parameters to trigger manifest error.
const errorParam = { params: { max_manifest_version: 2 } };
this.collector.addWarning({
instancePath,
...messages.manifestFieldUnsupported(instancePath, errorParam),
});
}
};
checkBrowserStyleInManifestKey('action');
checkBrowserStyleInManifestKey('options_ui');
checkBrowserStyleInManifestKey('page_action');
checkBrowserStyleInManifestKey('sidebar_action');
}
validateRestrictedPermissions() {
const permissions = Array.isArray(this.parsedJSON.permissions)
? this.parsedJSON.permissions
: [];
const permissionsInManifest = permissions.map((permission) =>
String(permission).toLowerCase()
);
if (permissionsInManifest.length === 0) {
return;
}
const minVersionSetInManifest = String(
this.getMetadata().firefoxMinVersion
);
for (const permission of this.restrictedPermissions.keys()) {
if (permissionsInManifest.includes(permission)) {
const permMinVersion = this.restrictedPermissions.get(permission);
if (
!minVersionSetInManifest ||
mozCompare(minVersionSetInManifest, permMinVersion) === -1
) {
this.collector.addError(
messages.makeRestrictedPermission(permission, permMinVersion)
);
this.isValid = false;
}
}
}
}
validateExtensionID() {
if (this.parsedJSON.manifest_version < 3) {
return;
}
if (!this.parsedJSON.applications?.gecko?.id) {
this.collector.addError(messages.EXTENSION_ID_REQUIRED);
this.isValid = false;
}
}
async validateIcon(iconPath, expectedSize) {
try {
const info = await getImageMetadata(this.io, iconPath);
if (info.width !== info.height) {
if (info.mime !== 'image/svg+xml') {
this.collector.addError(messages.iconIsNotSquare(iconPath));
this.isValid = false;
} else {
this.collector.addWarning(messages.iconIsNotSquare(iconPath));
}
} else if (
expectedSize !== null &&
info.mime !== 'image/svg+xml' &&
parseInt(info.width, 10) !== parseInt(expectedSize, 10)
) {
this.collector.addWarning(
messages.iconSizeInvalid({
path: iconPath,
expected: parseInt(expectedSize, 10),
actual: parseInt(info.width, 10),
})
);
}
} catch (err) {
log.debug(
`Unexpected error raised while validating icon "${iconPath}"`,
err
);
this.collector.addWarning(
messages.corruptIconFile({
path: iconPath,
})
);
}
}
validateIcons() {
const icons = [];
if (this.parsedJSON.icons) {
Object.keys(this.parsedJSON.icons).forEach((size) => {
icons.push([size, this.parsedJSON.icons[size]]);
});
}
// Check for default_icon key at each of the action properties
['browser_action', 'page_action', 'sidebar_action'].forEach((key) => {
if (this.parsedJSON[key] && this.parsedJSON[key].default_icon) {
if (typeof this.parsedJSON[key].default_icon === 'string') {
icons.push([null, this.parsedJSON[key].default_icon]);
} else {
Object.keys(this.parsedJSON[key].default_icon).forEach((size) => {
icons.push([size, this.parsedJSON[key].default_icon[size]]);
});
}
}
});
// Check for the theme_icons from the browser_action
if (
this.parsedJSON.browser_action &&
this.parsedJSON.browser_action.theme_icons
) {
this.parsedJSON.browser_action.theme_icons.forEach((icon) => {
['dark', 'light'].forEach((theme) => {
if (icon[theme]) {
icons.push([icon.size, icon[theme]]);
}
});
});
}
const promises = [];
const errorIcons = [];
icons.forEach(([size, iconPath]) => {
const _path = normalizePath(iconPath);
if (!Object.prototype.hasOwnProperty.call(this.io.files, _path)) {
if (!errorIcons.includes(_path)) {
this.collector.addError(messages.manifestIconMissing(_path));
this.isValid = false;
errorIcons.push(_path);
}
} else if (
!IMAGE_FILE_EXTENSIONS.includes(getNormalizedExtension(_path))
) {
if (!errorIcons.includes(_path)) {
this.collector.addWarning(messages.WRONG_ICON_EXTENSION);
}
} else {
promises.push(this.validateIcon(_path, size));
}
});
return Promise.all(promises);
}
async validateThemeImage(imagePath, manifestPropName) {
const _path = normalizePath(imagePath);
const ext = getNormalizedExtension(imagePath);
const fileExists = this.validateFileExistsInPackage(
_path,
`theme.images.${manifestPropName}`,
messages.manifestThemeImageMissing
);
// No need to validate the image format if the file doesn't exist
// on disk.
if (!fileExists) {
return;
}
if (!IMAGE_FILE_EXTENSIONS.includes(ext) || ext === 'webp') {
this.collector.addError(
messages.manifestThemeImageWrongExtension({ path: _path })
);
this.isValid = false;
return;
}
try {
const info = await getImageMetadata(this.io, _path);
if (
!STATIC_THEME_IMAGE_MIMES.includes(info.mime) ||
info.mime === 'image/webp'
) {
this.collector.addError(
messages.manifestThemeImageWrongMime({
path: _path,
mime: info.mime,
})
);
this.isValid = false;
} else if (FILE_EXTENSIONS_TO_MIME[ext] !== info.mime) {
this.collector.addWarning(
messages.manifestThemeImageMimeMismatch({
path: _path,
mime: info.mime,
})
);
}
} catch (err) {
log.debug(
`Unexpected error raised while validating theme image "${_path}"`,
err.message
);
this.collector.addError(
messages.manifestThemeImageCorrupted({ path: _path })
);
this.isValid = false;
}
}
validateStaticThemeImages() {
const promises = [];
const themeImages = this.parsedJSON.theme && this.parsedJSON.theme.images;
// The theme.images manifest property is mandatory on Firefox < 60, but optional
// on Firefox >= 60.
if (themeImages) {
for (const prop of Object.keys(themeImages)) {
if (Array.isArray(themeImages[prop])) {
themeImages[prop].forEach((imagePath) => {
promises.push(this.validateThemeImage(imagePath, prop));
});
} else {
promises.push(this.validateThemeImage(themeImages[prop], prop));
}
}
}
return Promise.all(promises);
}
validateFileExistsInPackage(
filePath,
type,
messageFunc = messages.manifestBackgroundMissing
) {
const _path = normalizePath(filePath);
if (!Object.prototype.hasOwnProperty.call(this.io.files, _path)) {
this.collector.addError(messageFunc(_path, type));
this.isValid = false;
return false;
}
return true;
}
validateContentScriptMatchPattern(matchPattern) {
BLOCKED_CONTENT_SCRIPT_HOSTS.split('\n').forEach((value) => {
if (value && value.length > 0 && value.substr(0, 1) !== '#') {
if (matchPattern.includes(value.trim())) {
this.collector.addError(messages.MANIFEST_INVALID_CONTENT);
this.isValid = false;
}
}
});
}
validateCspPolicy(policy) {
if (typeof policy === 'string') {
this.validateCspPolicyString(policy, 'content_security_policy');
} else if (policy != null) {
const keys = Object.keys(policy);
for (const key of keys) {
this.validateCspPolicyString(
policy[key],
`content_security_policy.${key}`
);
}
}
}
validateCspPolicyString(policy, manifestPropName) {
if (typeof policy !== 'string') {
return;
}
const directives = parseCspPolicy(policy);
// The order is important here, 'default-src' needs to be before
// 'script-src' to ensure it can overwrite default-src security policies
const candidates = [
'default-src',
'script-src',
'script-src-elem',
'script-src-attr',
'worker-src',
];
const isSecureCspValue = (value) => CSP_KEYWORD_RE.test(value);
// A missing default-src directive is very permissive, thus insecure:
let insecureSrcDirective = !directives['default-src'];
let warnInsecureCsp = insecureSrcDirective;
let warnInsecureEval = false;
for (let i = 0; i < candidates.length; i++) {
/* eslint-disable no-continue */
const candidate = candidates[i];
if (Object.prototype.hasOwnProperty.call(directives, candidate)) {
const values = directives[candidate];
// If the 'default-src' is insecure, check whether the 'script-src'
// makes it secure, ie 'script-src: self;'
//
// NOTE: this is not yet considering script-src-elem and script-src-attr,
// and it can't be extended to them as is, each of them on their
// own would not fully cover an insecure src directive and they would
// need to be appropriately combined with other directives.
if (
insecureSrcDirective &&
candidate === 'script-src' &&
values.every(isSecureCspValue)
) {
insecureSrcDirective = false;
warnInsecureCsp = false;
continue;
}
for (const value of values) {
// Add a more detailed message for unsafe-eval to avoid confusion
// about why it's forbidden.
if (value === "'unsafe-eval'") {
warnInsecureEval = true;
continue;
}
if (!isSecureCspValue(value)) {
warnInsecureCsp = true;
// everything else looks like something we don't understand
// / support otherwise is invalid so let's warn about that.
if (candidate === 'default-src') {
// Remember insecure 'default-src' to check whether a later
// 'script-src' makes it secure
insecureSrcDirective = true;
}
continue;
}
}
}
}
if (warnInsecureEval) {
this.collector.addWarning(
messages.manifestCspUnsafeEval(manifestPropName)
);
}
if (warnInsecureCsp) {
this.collector.addWarning(messages.manifestCsp(manifestPropName));
}
}
validateHomePageURL(url) {
for (const restrictedUrl of RESTRICTED_HOMEPAGE_URLS) {
if (url.includes(restrictedUrl)) {
this.collector.addError(messages.RESTRICTED_HOMEPAGE_URL);
this.isValid = false;
return;
}
}
}
validateIncognito() {
if (this.parsedJSON.incognito === 'split') {
this.collector.addWarning(messages.INCOGNITO_SPLIT_UNSUPPORTED);
}
}
getAddonId() {
try {
const { id } = this.parsedJSON.applications.gecko;
return typeof id === 'undefined' ? null : id;
} catch (e) {
log.error('Failed to get the id from the manifest.');
return null;
}
}
getExperimentApiPaths() {
const apiPaths = new Set();
const { experiment_apis } = this.parsedJSON;
if (experiment_apis) {
// We need to build a list of API "paths" for each registered experimental
// API. The data in the `manifest.json` would look like this:
//
// "experiment_apis": {
// "some-name": {
// "schema": "experiments/some-name/schema.json",
// "parent": {
// "scopes": ["addon_parent"],
// "script": "experiments/some-name/api.js",
// "paths": [["some", "name"]]
// }
// }
//
// We are interested in the `paths` array (of array), which contains API
// "paths". We need to get each entry for each experiment and we build API
// "paths" like:
//
// Set(['some.name'])
//
// We could have either a "parent" or "child" property for each API, or
// both although it is less common.
//
for (const key of Object.keys(experiment_apis)) {
const { child, parent } = experiment_apis[key];
const parentPaths = parent?.paths ?? [];
const childPaths = child?.paths ?? [];
[...parentPaths, ...childPaths]
.filter((p) => Array.isArray(p) && p.length)
.forEach((p) => apiPaths.add(p.join('.')));
}
}
return apiPaths;
}
/**
* @typedef {Object} Metadata
* @property {string} id
* @property {number} manifestVersion
* @property {string} name
* @property {number} type
* @property {string} version
* @property {string} firefoxMinVersion
* @property {string} firefoxStrictMinVersion
* @property {Set<string>} experimentApiPaths
*
* @returns {Metadata}
*/
getMetadata() {
return {
id: this.getAddonId(),
manifestVersion: this.parsedJSON.manifest_version,
name: this.parsedJSON.name,
type: PACKAGE_EXTENSION,
version: this.parsedJSON.version,
// This is the `strict_min_version` value set in the `manifest.json` file
// for Firefox for desktop.
firefoxMinVersion:
this.parsedJSON.applications &&
this.parsedJSON.applications.gecko &&
this.parsedJSON.applications.gecko.strict_min_version,
// This is the strict min *major* version for Firefox for desktop.
firefoxStrictMinVersion: firefoxStrictMinVersion(this.parsedJSON),
experimentApiPaths: this.getExperimentApiPaths(),
};
}
}