Skip to content

Commit

Permalink
Added sanitization for svg uploads (#20264)
Browse files Browse the repository at this point in the history
ref https://linear.app/tryghost/issue/ENG-856
- svgs were not previously sanitized and could contain scripts
  • Loading branch information
9larsons committed May 28, 2024
1 parent d5cf717 commit e6fcbf4
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 11 deletions.
35 changes: 32 additions & 3 deletions ghost/core/core/server/web/api/middleware/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const errors = require('@tryghost/errors');
const config = require('../../../../shared/config');
const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
const {JSDOM} = require('jsdom');

const messages = {
db: {
Expand Down Expand Up @@ -144,14 +145,33 @@ const checkFileExists = (fileData) => {

const checkFileIsValid = (fileData, types, extensions) => {
const type = fileData.mimetype;

if (types.includes(type) && extensions.includes(fileData.ext)) {
return true;
}

return false;
};

/**
*
* @param {String} filepath
* @returns {Boolean}
*
* Checks for the presence of <script> tags or 'on' attributes in an SVG file
*
*/
const isSvgSafe = (filepath) => {
const fileContent = fs.readFileSync(filepath, 'utf8');
const document = new JSDOM(fileContent).window.document;
document.body.innerHTML = fileContent;
const svgEl = document.body.firstElementChild;

const attributes = Array.from(svgEl.attributes).map(({name}) => name);
const hasScriptAttr = !!attributes.find(attr => attr.startsWith('on'));
const scripts = svgEl.getElementsByTagName('script');

return scripts.length === 0 && !hasScriptAttr ? true : false;
};

/**
*
* @param {Object} options
Expand Down Expand Up @@ -190,6 +210,14 @@ const validation = function ({type}) {
}));
}

if (req.file.ext === '.svg') {
if (!isSvgSafe(req.file.path)) {
return next(new errors.UnsupportedMediaTypeError({
message: 'SVG files cannot contain <script> tags or "on" attributes.'
}));
}
}

next();
};
};
Expand Down Expand Up @@ -261,5 +289,6 @@ module.exports = {
// Exports for testing only
module.exports._test = {
checkFileExists,
checkFileIsValid
checkFileIsValid,
isSvgSafe
};
40 changes: 32 additions & 8 deletions ghost/core/test/unit/server/web/api/middleware/upload.test.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
const should = require('should');
const validation = require('../../../../../../core/server/web/api/middleware/upload')._test;
const imageFixturePath = ('../../../../../utils/fixtures/images/');
const fs = require('fs');
const path = require('path');

describe('web utils', function () {
describe('checkFileExists', function () {
it('should return true if file exists in input', function () {
validation.checkFileExists({mimetype: 'file', path: 'path'}).should.be.true();
validation.checkFileExists({mimetype: 'file', path: 'path'}).should.be.true;
});

it('should return false if file does not exist in input', function () {
validation.checkFileExists({}).should.be.false();
validation.checkFileExists({}).should.be.false;
});

it('should return false if file is incorrectly structured', function () {
validation.checkFileExists({type: 'file'}).should.be.false();
validation.checkFileExists({type: 'file'}).should.be.false;
});
});

Expand All @@ -22,22 +25,43 @@ describe('web utils', function () {
name: 'test.txt',
mimetype: 'text',
ext: '.txt'
}, ['text'], ['.txt']).should.be.true();
}, ['text'], ['.txt']).should.be.true;

validation.checkFileIsValid({
name: 'test.jpg',
mimetype: 'jpeg',
ext: '.jpg'
}, ['text', 'jpeg'], ['.txt', '.jpg']).should.be.true();
}, ['text', 'jpeg'], ['.txt', '.jpg']).should.be.true;
});

it('returns false if file has invalid extension', function () {
validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']).should.be.false();
validation.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']).should.be.false();
validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']).should.be.false;
validation.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']).should.be.false;
});

it('returns false if file has invalid type', function () {
validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']).should.be.false();
validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']).should.be.false;
});
});

describe('isSvgSafe', function () {
it('detects a <script> tag in a svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-script.svg');
const dirtySvgContent = fs.readFileSync(filepath, 'utf8');
dirtySvgContent.should.containEql('<script');
validation.isSvgSafe(filepath).should.be.false;
});
it('detects a on attribute in a svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-script2.svg');
const dirtySvgContent = fs.readFileSync(filepath, 'utf8');
dirtySvgContent.should.containEql('onclick');
validation.isSvgSafe(filepath).should.be.false;
});
it('returns true for a safe svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'ghost-logo.svg');
const dirtySvgContent = fs.readFileSync(filepath, 'utf8');
dirtySvgContent.should.not.containEql('<script');
validation.isSvgSafe(filepath).should.be.true;
});
});
});
8 changes: 8 additions & 0 deletions ghost/core/test/utils/fixtures/images/svg-with-script.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions ghost/core/test/utils/fixtures/images/svg-with-script2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit e6fcbf4

Please sign in to comment.