From c98fac30b6040c8822f8f737ce90590f7a79d95a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 10:52:56 -0500 Subject: [PATCH] Update:Validate image URI content-type before writing image file --- server/finders/AuthorFinder.js | 4 ++-- server/managers/CoverManager.js | 6 +++--- server/utils/fileUtils.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js index 59c6ce16..69aa724d 100644 --- a/server/finders/AuthorFinder.js +++ b/server/finders/AuthorFinder.js @@ -3,7 +3,7 @@ const Logger = require('../Logger') const Path = require('path') const Audnexus = require('../providers/Audnexus') -const { downloadFile } = require('../utils/fileUtils') +const { downloadImageFile } = require('../utils/fileUtils') class AuthorFinder { constructor() { @@ -45,7 +45,7 @@ class AuthorFinder { const filename = authorId + '.' + ext const outputPath = Path.posix.join(authorDir, filename) - return downloadFile(url, outputPath).then(() => { + return downloadImageFile(url, outputPath).then(() => { return { path: outputPath } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 934deaff..3cf97f33 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -5,7 +5,7 @@ const readChunk = require('../libs/readChunk') const imageType = require('../libs/imageType') const globals = require('../utils/globals') -const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') +const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') const CacheManager = require('../managers/CacheManager') @@ -122,7 +122,7 @@ class CoverManager { var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' - let success = await downloadFile(url, temppath).then(() => true).catch((err) => { + let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { errorMsg = err.message || 'Unknown error' Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) return false @@ -287,7 +287,7 @@ class CoverManager { await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') - const success = await downloadFile(url, temppath).then(() => true).catch((err) => { + const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) return false }) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 37e89029..4df26400 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -204,7 +204,16 @@ async function recurseFiles(path, relPathToReplace = null) { } module.exports.recurseFiles = recurseFiles -module.exports.downloadFile = (url, filepath) => { +/** + * Download file from web to local file system + * Uses SSRF filter to prevent internal URLs + * + * @param {string} url + * @param {string} filepath path to download the file to + * @param {Function} [contentTypeFilter] validate content type before writing + * @returns {Promise} + */ +module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { return new Promise(async (resolve, reject) => { Logger.debug(`[fileUtils] Downloading file to ${filepath}`) axios({ @@ -215,6 +224,12 @@ module.exports.downloadFile = (url, filepath) => { httpAgent: ssrfFilter(url), httpsAgent: ssrfFilter(url) }).then((response) => { + // Validate content type + if (contentTypeFilter && !contentTypeFilter?.(response.headers?.['content-type'])) { + return reject(new Error(`Invalid content type "${response.headers?.['content-type'] || ''}"`)) + } + + // Write to filepath const writer = fs.createWriteStream(filepath) response.data.pipe(writer) @@ -227,6 +242,21 @@ module.exports.downloadFile = (url, filepath) => { }) } +/** + * Download image file from web to local file system + * Response header must have content-type of image/ (excluding svg) + * + * @param {string} url + * @param {string} filepath + * @returns {Promise} + */ +module.exports.downloadImageFile = (url, filepath) => { + const contentTypeFilter = (contentType) => { + return contentType?.startsWith('image/') && contentType !== 'image/svg+xml' + } + return this.downloadFile(url, filepath, contentTypeFilter) +} + module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { if (typeof filename !== 'string') { return false