fix: Detect unsupported images used in static themes manifest

This commit is contained in:
Luca Greco 2018-11-07 21:50:30 +01:00
Родитель d17cd9d8d6
Коммит f810d6186a
8 изменённых файлов: 567 добавлений и 0 удалений

Просмотреть файл

@ -121,3 +121,13 @@ Rules are sorted by severity.
| `MANIFEST_MULTIPLE_DICTS` | error | Multiple dictionaries found |
| `MANIFEST_EMPTY_DICTS` | error | Empty `dictionaries` object |
| `MANIFEST_DICT_MISSING_ID` | error | Missing `applications.gecko.id` property for a dictionary |
### Static Theme / manifest.json
| Message code | Severity | Description |
| ------------------------------------ | -------- | ----------------------------------------------------------- |
| `MANIFEST_THEME_IMAGE_MIME_MISMATCH` | warning | Theme image file extension should match its mime type |
| `MANIFEST_THEME_IMAGE_NOT_FOUND` | error | Theme images must not be missing |
| `MANIFEST_THEME_IMAGE_CORRUPTED` | error | Theme images must not be corrupted |
| `MANIFEST_THEME_IMAGE_WRONG_EXT` | error | Theme images must have one of the supported file extensions |
| `MANIFEST_THEME_IMAGE_WRONG_MIME` | error | Theme images mime type must be a supported format |

Просмотреть файл

@ -109,6 +109,19 @@ export const IMAGE_FILE_EXTENSIONS = [
'svg',
];
// Map the image mime to the expected file extensions
// (used in the the static theme images validation).
export const MIME_TO_FILE_EXTENSIONS = {
'image/svg+xml': ['svg'],
'image/gif': ['gif'],
'image/jpeg': ['jpg', 'jpeg'],
'image/png': ['png'],
'image/webp': ['webp'],
};
// List of the mime types for the allowed static theme images.
export const STATIC_THEME_IMAGE_MIMES = Object.keys(MIME_TO_FILE_EXTENSIONS);
// A list of magic numbers that we won't allow.
export const FLAGGED_FILE_MAGIC_NUMBERS = [
[0x4d, 0x5a], // EXE or DLL,

Просмотреть файл

@ -259,6 +259,10 @@ export default class Linter {
if (manifestParser.parsedJSON.icons) {
await manifestParser.validateIcons();
}
if (manifestParser.isStaticTheme) {
await manifestParser.validateStaticThemeImages();
}
this.addonMetadata = manifestParser.getMetadata();
} else {
_log.warn(

Просмотреть файл

@ -296,6 +296,84 @@ export function corruptIconFile({ path }) {
};
}
export const MANIFEST_THEME_IMAGE_NOT_FOUND = 'MANIFEST_THEME_IMAGE_NOT_FOUND';
export function manifestThemeImageMissing(path, type) {
return {
code: MANIFEST_THEME_IMAGE_NOT_FOUND,
message: i18n.sprintf(
'Theme image for "%(type)s" could not be found in the package',
{ type }
),
description: i18n.sprintf(
i18n._('Theme image for "%(type)s" could not be found at "%(path)s"'),
{ path, type }
),
file: MANIFEST_JSON,
};
}
export const MANIFEST_THEME_IMAGE_CORRUPTED = 'MANIFEST_THEME_IMAGE_CORRUPTED';
export function manifestThemeImageCorrupted({ path }) {
return {
code: MANIFEST_THEME_IMAGE_CORRUPTED,
message: i18n._('Corrupted theme image file'),
description: i18n.sprintf(
i18n._('Theme image file at "%(path)s" is corrupted'),
{ path }
),
file: MANIFEST_JSON,
};
}
export const MANIFEST_THEME_IMAGE_WRONG_EXT = 'MANIFEST_THEME_IMAGE_WRONG_EXT';
export function manifestThemeImageWrongExtension({ path }) {
return {
code: MANIFEST_THEME_IMAGE_WRONG_EXT,
message: i18n._('Theme image file has an unsupported file extension'),
description: i18n.sprintf(
i18n._(
'Theme image file at "%(path)s" has an unsupported file extension'
),
{ path }
),
file: MANIFEST_JSON,
};
}
export const MANIFEST_THEME_IMAGE_WRONG_MIME =
'MANIFEST_THEME_IMAGE_WRONG_MIME';
export function manifestThemeImageWrongMime({ path, mime }) {
return {
code: MANIFEST_THEME_IMAGE_WRONG_MIME,
message: i18n._('Theme image file has an unsupported mime type'),
description: i18n.sprintf(
i18n._(
'Theme image file at "%(path)s" has the unsupported mime type "%(mime)s"'
),
{ path, mime }
),
file: MANIFEST_JSON,
};
}
export const MANIFEST_THEME_IMAGE_MIME_MISMATCH =
'MANIFEST_THEME_IMAGE_MIME_MISMATCH';
export function manifestThemeImageMimeMismatch({ path, mime }) {
return {
code: MANIFEST_THEME_IMAGE_MIME_MISMATCH,
message: i18n._(
'Theme image file mime type does not match its file extension'
),
description: i18n.sprintf(
i18n._(
'Theme image file extension at "%(path)s" does not match its actual mime type "%(mime)s"'
),
{ path, mime }
),
file: MANIFEST_JSON,
};
}
export const PROP_NAME_MISSING = manifestPropMissing('name');
export const PROP_VERSION_MISSING = manifestPropMissing('version');

Просмотреть файл

@ -21,6 +21,8 @@ import {
IMAGE_FILE_EXTENSIONS,
LOCALES_DIRECTORY,
MESSAGES_JSON,
STATIC_THEME_IMAGE_MIMES,
MIME_TO_FILE_EXTENSIONS,
} from 'const';
import log from 'logger';
import * as messages from 'messages';
@ -392,6 +394,87 @@ export default class ManifestJSONParser extends JSONParser {
return Promise.all(promises);
}
async validateThemeImage(imagePath, manifestPropName) {
const _path = normalizePath(imagePath);
const ext = path
.extname(imagePath)
.substring(1)
.toLowerCase();
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 (!MIME_TO_FILE_EXTENSIONS[info.mime].includes(ext)) {
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,
@ -401,7 +484,9 @@ export default class ManifestJSONParser extends JSONParser {
if (!Object.prototype.hasOwnProperty.call(this.io.files, _path)) {
this.collector.addError(messageFunc(_path, type));
this.isValid = false;
return false;
}
return true;
}
validateContentScriptMatchPattern(matchPattern) {

Просмотреть файл

@ -14,12 +14,51 @@ export const fakeMessageData = {
message: 'message',
};
export const EMPTY_SVG = Buffer.from('<svg viewbox="0 0 1 1"></svg>');
export const EMPTY_PNG = Buffer.from(
oneLine`iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMA
AQAABQABDQottAAAAABJRU5ErkJggg==`,
'base64'
);
export const EMPTY_GIF = Buffer.from(
'R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
'base64'
);
export const EMPTY_APNG = Buffer.from(
oneLine`iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAA1BMVEUAAACn
ej3aAAAAAXRSTlMAQObYZgAAAA1JREFUCNcBAgD9/wAAAAIAAXdw4VoAAAAY
dEVYdFNvZnR3YXJlAGdpZjJhcG5nLnNmLm5ldJb/E8gAAAAASUVORK5CYII=`,
'base64'
);
export const EMPTY_JPG = Buffer.from(
oneLine`/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQE
BAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/
wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAA
AAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==`,
'base64'
);
export const EMPTY_WEBP = Buffer.from(
oneLine`UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAY
AAAAMAEAnQEqAQABAAEAHCWkAANwAP7+BtAA`,
'base64'
);
export const EMPTY_TIFF = Buffer.from(
oneLine`SUkqABIAAAB42mNgAAAAAgABEQAAAQMAAQAAAAEAAAABAQMAAQAAAAEAAAAC
AQMAAgAAAAgACAADAQMAAQAAAAgAAAAGAQMAAQAAAAEAAAAKAQMAAQAAAAEA
AAARAQQAAQAAAAgAAAASAQMAAQAAAAEAAAAVAQMAAQAAAAIAAAAWAQMAAQAA
AAEAAAAXAQQAAQAAAAoAAAAcAQMAAQAAAAEAAAApAQMAAgAAAAAAAQA9AQMA
AQAAAAIAAAA+AQUAAgAAABQBAAA/AQUABgAAAOQAAABSAQMAAQAAAAIAAAAA
AAAA/wnXo/////9/4XpU///////MzEz//////5mZmf////9/ZmYm/////+8o
XA//////fxsNUP//////VzlU/////w==`,
'base64'
);
export function getRuleFiles(ruleType) {
const ruleFiles = fs.readdirSync(`src/rules/${ruleType}`);

Просмотреть файл

@ -1,5 +1,7 @@
import fs from 'fs';
import { oneLine } from 'common-tags';
import Linter from 'linter';
import ManifestJSONParser from 'parsers/manifestjson';
import { PACKAGE_EXTENSION, VALID_MANIFEST_VERSION } from 'const';
@ -13,6 +15,12 @@ import {
validStaticThemeManifestJSON,
getStreamableIO,
EMPTY_PNG,
EMPTY_APNG,
EMPTY_GIF,
EMPTY_JPG,
EMPTY_SVG,
EMPTY_TIFF,
EMPTY_WEBP,
} from '../helpers';
describe('ManifestJSONParser', () => {
@ -1536,6 +1544,290 @@ describe('ManifestJSONParser', () => {
description: 'Your JSON file could not be parsed.',
});
});
it('adds a validation error on missing theme image files', async () => {
const propName = 'theme.images.headerURL';
const fileName = 'missing-image-file.png';
const linter = new Linter({ _: ['bar'] });
const manifest = validStaticThemeManifestJSON({
theme: {
images: {
headerURL: fileName,
},
},
});
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{
io: { files: {} },
}
);
await manifestJSONParser.validateStaticThemeImages();
expect(manifestJSONParser.isValid).toEqual(false);
assertHasMatchingError(linter.collector.errors, {
code: messages.MANIFEST_THEME_IMAGE_NOT_FOUND,
message: `Theme image for "${propName}" could not be found in the package`,
description: `Theme image for "${propName}" could not be found at "${fileName}"`,
});
});
it('adds a validation error on theme image file with unsupported file extension', async () => {
const files = {
'unsupported-image-ext.tiff': '',
'unsupported-image-ext.webp': '',
};
const fileNames = Object.keys(files);
const linter = new Linter({ _: ['bar'] });
const manifest = validStaticThemeManifestJSON({
theme: {
images: {
headerURL: fileNames[0],
additional_backgrounds: fileNames[1],
},
},
});
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{ io: { files } }
);
await manifestJSONParser.validateStaticThemeImages();
expect(manifestJSONParser.isValid).toEqual(false);
for (const name of fileNames) {
assertHasMatchingError(linter.collector.errors, {
code: messages.MANIFEST_THEME_IMAGE_WRONG_EXT,
message: `Theme image file has an unsupported file extension`,
description: `Theme image file at "${name}" has an unsupported file extension`,
});
}
});
it('adds a validation error on theme image file in unsupported formats', async () => {
const files = {
'tiff-image-with-png-filext.png': EMPTY_TIFF,
'webp-image-with-png-filext.png': EMPTY_WEBP,
};
const fileNames = Object.keys(files);
const fileMimes = ['image/tiff', 'image/webp'];
const linter = new Linter({ _: ['bar'] });
const manifest = validStaticThemeManifestJSON({
theme: {
images: {
headerURL: fileNames[0],
addional_backgrounds: fileNames[1],
},
},
});
const fakeIO = getStreamableIO(files);
fakeIO.getFileAsStream = jest.fn(fakeIO.getFileAsStream);
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{ io: fakeIO }
);
await manifestJSONParser.validateStaticThemeImages();
// Expect getFileAsStream to have been called to read the
// image file.
expect(fakeIO.getFileAsStream.mock.calls.length).toBe(2);
for (let i = 0; i < fileNames; i++) {
expect(fakeIO.getFileAsStream.mock.calls[i]).toEqual([
fileNames[i],
{ encoding: null },
]);
}
expect(manifestJSONParser.isValid).toEqual(false);
for (let i = 0; i < fileNames; i++) {
const fileName = fileNames[i];
const fileMime = fileMimes[i];
assertHasMatchingError(linter.collector.errors, {
code: messages.MANIFEST_THEME_IMAGE_WRONG_MIME,
message: `Theme image file has an unsupported mime type`,
description: `Theme image file at "${fileName}" has the unsupported mime type "${fileMime}"`,
});
}
});
it('adds a validation warning on supported theme image mime with file extension mismatch', async () => {
const fileName = 'png-image-with-gif-filext.gif';
const fileMime = 'image/png';
const linter = new Linter({ _: ['bar'] });
const manifest = validStaticThemeManifestJSON({
theme: {
images: {
headerURL: fileName,
},
},
});
const files = { [fileName]: EMPTY_PNG };
const fakeIO = getStreamableIO(files);
fakeIO.getFileAsStream = jest.fn(fakeIO.getFileAsStream);
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{ io: fakeIO }
);
await manifestJSONParser.validateStaticThemeImages();
// Expect getFileAsStream to have been called to read the
// image file.
expect(fakeIO.getFileAsStream.mock.calls.length).toBe(1);
expect(fakeIO.getFileAsStream.mock.calls[0]).toEqual([
fileName,
{ encoding: null },
]);
expect(manifestJSONParser.isValid).toEqual(true);
assertHasMatchingError(linter.collector.warnings, {
code: messages.MANIFEST_THEME_IMAGE_MIME_MISMATCH,
message: `Theme image file mime type does not match its file extension`,
description: oneLine`Theme image file extension at "${fileName}"
does not match its actual mime type "${fileMime}"`,
});
});
it('adds a validation error if unable to validate theme images files mime type', async () => {
const fileName = 'corrupted-image-file.png';
const linter = new Linter({ _: ['bar'] });
const manifest = validStaticThemeManifestJSON({
theme: {
images: {
headerURL: fileName,
},
},
});
// Set the image file content as empty, so that the validation is going to be
// unable to retrive the file mime type.
const files = { [fileName]: '' };
const fakeIO = getStreamableIO(files);
fakeIO.getFileAsStream = jest.fn(fakeIO.getFileAsStream);
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{ io: fakeIO }
);
await manifestJSONParser.validateStaticThemeImages();
// Expect getFileAsStream to have been called to read the
// image file.
expect(fakeIO.getFileAsStream.mock.calls.length).toBe(1);
expect(fakeIO.getFileAsStream.mock.calls[0]).toEqual([
fileName,
{ encoding: null },
]);
expect(manifestJSONParser.isValid).toEqual(false);
assertHasMatchingError(linter.collector.errors, {
code: messages.MANIFEST_THEME_IMAGE_CORRUPTED,
message: `Corrupted theme image file`,
description: `Theme image file at "${fileName}" is corrupted`,
});
});
it('validates all image paths when the manifest property value is an array', async () => {
const linter = new Linter({ _: ['bar'] });
const imageFiles = [
'bg1.svg',
'bg2.png',
'bg2-apng.png',
'bg3.gif',
'bg4.jpg',
'bg4-1.jpeg',
];
const manifest = validStaticThemeManifestJSON({
theme: {
images: {
additional_backgrounds: imageFiles,
},
},
});
const files = {
'bg1.svg': EMPTY_SVG,
'bg2.png': EMPTY_PNG,
'bg2-apng.png': EMPTY_APNG,
'bg3.gif': EMPTY_GIF,
'bg4.jpg': EMPTY_JPG,
'bg4-1.jpeg': EMPTY_JPG,
};
const fakeIO = getStreamableIO(files);
fakeIO.getFileAsStream = jest.fn(fakeIO.getFileAsStream);
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{ io: fakeIO }
);
await manifestJSONParser.validateStaticThemeImages();
// Expect getFileAsStream to have been called to read all the image files.
expect(fakeIO.getFileAsStream.mock.calls.length).toBe(imageFiles.length);
expect(fakeIO.getFileAsStream.mock.calls.map((call) => call[0])).toEqual(
imageFiles
);
const { errors, warnings } = linter.collector;
expect({ errors, warnings }).toEqual({
errors: [],
warnings: [],
});
expect(manifestJSONParser.isValid).toEqual(true);
});
it('considers theme.images as optional', async () => {
const linter = new Linter({ _: ['bar'] });
const manifest = validStaticThemeManifestJSON({
theme: {
colors: {
accentcolor: '#adb09f',
textcolor: '#000',
background_tab_text: 'rgba(255, 192, 0, 0)',
toolbar_text: 'rgb(255, 255, 255),',
toolbar_field_text: 'hsl(120, 100%, 50%)',
},
},
});
const manifestJSONParser = new ManifestJSONParser(
manifest,
linter.collector,
{ io: { files: {} } }
);
await manifestJSONParser.validateStaticThemeImages();
const { errors, warnings } = linter.collector;
expect({ errors, warnings }).toEqual({
errors: [],
warnings: [],
});
expect(manifestJSONParser.isValid).toEqual(true);
});
});
describe('locales', () => {

Просмотреть файл

@ -16,6 +16,7 @@ import { Xpi } from 'io';
import {
fakeMessageData,
validManifestJSON,
validStaticThemeManifestJSON,
EMPTY_PNG,
assertHasMatchingError,
} from './helpers';
@ -688,6 +689,51 @@ describe('Linter.getAddonMetadata()', () => {
expect(notices.length).toEqual(1);
expect(notices[0].code).toEqual(messages.TYPE_NO_MANIFEST_JSON.code);
});
it('should validate static theme images defined in the manifest', async () => {
const files = { 'manifest.json': {} };
const getFiles = async () => files;
// Spy the manifest parser.
let spyManifestParser;
const FakeManifestParser = sinon.spy((...args) => {
spyManifestParser = new ManifestJSONParser(...args);
spyManifestParser.validateStaticThemeImages = sinon.spy();
return spyManifestParser;
});
// Test on a static theme manifest.
const addonLinterTheme = new Linter({ _: ['bar'] });
addonLinterTheme.io = {
getFiles,
getFileAsString: async () => {
return validStaticThemeManifestJSON({});
},
};
const themeMetadata = await addonLinterTheme.getAddonMetadata({
ManifestJSONParser: FakeManifestParser,
});
expect(themeMetadata.type).toEqual(constants.PACKAGE_EXTENSION);
sinon.assert.calledOnce(FakeManifestParser);
sinon.assert.calledOnce(spyManifestParser.validateStaticThemeImages);
// Test on a non theme manifest.
const addonLinterExt = new Linter({ _: ['bar'] });
addonLinterExt.io = {
getFiles,
getFileAsString: async () => {
return validManifestJSON({});
},
};
const extMetadata = await addonLinterExt.getAddonMetadata({
ManifestJSONParser: FakeManifestParser,
});
expect(extMetadata.type).toEqual(constants.PACKAGE_EXTENSION);
sinon.assert.calledTwice(FakeManifestParser);
sinon.assert.notCalled(spyManifestParser.validateStaticThemeImages);
});
});
describe('Linter.extractMetadata()', () => {