diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js index fc6ee9c1f1..ced7385983 100644 --- a/src/controllers/admin/uploads.js +++ b/src/controllers/admin/uploads.js @@ -3,6 +3,8 @@ const path = require('path'); const nconf = require('nconf'); const fs = require('fs'); +const winston = require('winston'); +const sanitizeHtml = require('sanitize-html'); const meta = require('../../meta'); const posts = require('../../posts'); @@ -22,9 +24,15 @@ uploadsController.get = async function (req, res, next) { } const itemsPerPage = 20; const page = parseInt(req.query.page, 10) || 1; + let files = []; + try { + await checkSymLinks(req.query.dir) + files = await getFilesInFolder(currentFolder); + } catch (err) { + winston.error(err.stack); + return next(new Error('[[error:invalid-path]]')); + } try { - let files = await fs.promises.readdir(currentFolder); - files = files.filter(filename => filename !== '.gitignore'); const itemCount = files.length; const start = Math.max(0, (page - 1) * itemsPerPage); const stop = start + itemsPerPage; @@ -64,6 +72,30 @@ uploadsController.get = async function (req, res, next) { } }; +async function checkSymLinks(folder) { + let dir = path.normalize(folder || ''); + while (dir.length && dir !== '.') { + const nextPath = path.join(nconf.get('upload_path'), dir); + // eslint-disable-next-line no-await-in-loop + const stat = await fs.promises.lstat(nextPath); + if (stat.isSymbolicLink()) { + throw new Error('[[invalid-path]]'); + } + dir = path.dirname(dir); + } +} + +async function getFilesInFolder(folder) { + const dirents = await fs.promises.readdir(folder, { withFileTypes: true }); + const files = []; + for await (const dirent of dirents) { + if (!dirent.isSymbolicLink() && dirent.name !== '.gitignore') { + files.push(dirent.name); + } + } + return files; +} + function buildBreadcrumbs(currentFolder) { const crumbs = []; const parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep); @@ -94,14 +126,14 @@ async function getFileData(currentDir, file) { const stat = await fs.promises.stat(pathToFile); let filesInDir = []; if (stat.isDirectory()) { - filesInDir = await fs.promises.readdir(pathToFile); + filesInDir = await getFilesInFolder(pathToFile); } const url = `${nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '')}/${file}`; return { name: file, path: pathToFile.replace(path.join(nconf.get('upload_path'), '/'), ''), url: url, - fileCount: Math.max(0, filesInDir.length - 1), // ignore .gitignore + fileCount: filesInDir.length, size: stat.size, sizeHumanReadable: `${(stat.size / 1024).toFixed(1)}KiB`, isDirectory: stat.isDirectory(), @@ -121,11 +153,50 @@ uploadsController.uploadCategoryPicture = async function (req, res, next) { return next(new Error('[[error:invalid-json]]')); } + if (uploadedFile.path.endsWith('.svg')) { + await sanitizeSvg(uploadedFile.path); + } + await validateUpload(uploadedFile, allowedImageTypes); const filename = `category-${params.cid}${path.extname(uploadedFile.name)}`; await uploadImage(filename, 'category', uploadedFile, req, res, next); }; +async function sanitizeSvg(filePath) { + const dirty = await fs.promises.readFile(filePath, 'utf8'); + const clean = sanitizeHtml(dirty, { + allowedTags: [ + 'svg', 'g', 'defs', 'linearGradient', 'radialGradient', 'stop', + 'circle', 'ellipse', 'polygon', 'polyline', 'path', 'rect', + 'line', 'text', 'tspan', 'use', 'symbol', 'clipPath', 'mask', 'pattern', + 'filter', 'feGaussianBlur', 'feOffset', 'feBlend', 'feColorMatrix', 'feMerge', 'feMergeNode', + ], + allowedAttributes: { + '*': [ + // Geometry + 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', + 'width', 'height', 'd', 'points', 'viewBox', 'transform', + + // Presentation + 'fill', 'stroke', 'stroke-width', 'opacity', + 'stop-color', 'stop-opacity', 'offset', 'style', 'class', + + // Text + 'text-anchor', 'font-size', 'font-family', + + // Misc + 'id', 'clip-path', 'mask', 'filter', 'gradientUnits', 'gradientTransform', + 'xmlns', 'preserveAspectRatio', + ], + }, + parser: { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }, + }); + await fs.promises.writeFile(filePath, clean); +} + uploadsController.uploadFavicon = async function (req, res, next) { const uploadedFile = req.files.files[0]; const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon']; @@ -197,6 +268,9 @@ uploadsController.uploadFile = async function (req, res, next) { return next(new Error('[[error:invalid-json]]')); } + if (!await file.exists(path.join(nconf.get('upload_path'), params.folder))) { + return next(new Error('[[error:invalid-path]]')); + } try { const data = await file.saveFileToLocal(uploadedFile.name, params.folder, uploadedFile.path); res.json([{ url: data.url }]); diff --git a/test/uploads.js b/test/uploads.js index a8e48afac5..76148d25d2 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -400,6 +400,17 @@ describe('Upload Controllers', () => { assert.strictEqual(body.error, '[[error:invalid-path]]'); }); + it('should fail to upload regular file if directory does not exist', async () => { + const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/admin/upload/file`, path.join(__dirname, '../test/files/test.png'), { + params: JSON.stringify({ + folder: 'does-not-exist', + }), + }, jar, csrf_token); + + assert.equal(response.statusCode, 500); + assert.strictEqual(body.error, '[[error:invalid-path]]'); + }); + describe('ACP uploads screen', () => { it('should create a folder', async () => { const { response } = await helpers.createFolder('', 'myfolder', jar, csrf_token);