From e1fa607f87d821accb846f2deef1f388003848d1 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Fri, 10 Oct 2025 23:06:06 +0300 Subject: [PATCH] Security Fix JVN#74210258: Stored XSS. Thanks to Ryoya Koyama of Mitsui Bussan Secure Directions, Inc and xet7 ! --- models/attachments.js | 18 ++++++++++++++++++ models/avatars.js | 15 +++++++++++++++ models/lib/httpStream.js | 16 +++++++++++++++- server/routes/legacyAttachments.js | 13 ++++++++++++- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/models/attachments.js b/models/attachments.js index 69e6cc83e..91b46d9bb 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -91,6 +91,24 @@ Attachments = new FilesCollection({ const ret = fileStoreStrategyFactory.storagePath; return ret; }, + onBeforeUpload(file) { + // Block SVG files for attachments to prevent XSS attacks + if (file.name && file.name.toLowerCase().endsWith('.svg')) { + if (process.env.DEBUG === 'true') { + console.warn('Blocked SVG file upload for attachment:', file.name); + } + return 'SVG files are not allowed for attachments due to security reasons. Please use PNG, JPG, GIF, or other safe formats.'; + } + + if (file.type === 'image/svg+xml') { + if (process.env.DEBUG === 'true') { + console.warn('Blocked SVG MIME type upload for attachment:', file.type); + } + return 'SVG files are not allowed for attachments due to security reasons. Please use PNG, JPG, GIF, or other safe formats.'; + } + + return true; + }, onAfterUpload(fileObj) { // current storage is the filesystem, update object and database Object.keys(fileObj.versions).forEach(versionName => { diff --git a/models/avatars.js b/models/avatars.js index 39dde0deb..760ef75b4 100644 --- a/models/avatars.js +++ b/models/avatars.js @@ -85,6 +85,21 @@ Avatars = new FilesCollection({ return ret; }, onBeforeUpload(file) { + // Block SVG files for avatars to prevent XSS attacks + if (file.name && file.name.toLowerCase().endsWith('.svg')) { + if (process.env.DEBUG === 'true') { + console.warn('Blocked SVG file upload for avatar:', file.name); + } + return 'SVG files are not allowed for avatars due to security reasons. Please use PNG, JPG, or GIF format.'; + } + + if (file.type === 'image/svg+xml') { + if (process.env.DEBUG === 'true') { + console.warn('Blocked SVG MIME type upload for avatar:', file.type); + } + return 'SVG files are not allowed for avatars due to security reasons. Please use PNG, JPG, or GIF format.'; + } + if (file.size <= avatarsUploadSize && file.type.startsWith('image/')) { return true; } diff --git a/models/lib/httpStream.js b/models/lib/httpStream.js index d397d734b..16ce6e34f 100644 --- a/models/lib/httpStream.js +++ b/models/lib/httpStream.js @@ -17,12 +17,26 @@ export const httpStreamOutput = function(readStream, name, http, downloadFlag, c if (cacheControl) { http.response.setHeader('Cache-Control', cacheControl); } + + // Set Content-Disposition header http.response.setHeader('Content-Disposition', getContentDisposition(name, http?.params?.query?.download)); + + // Add security headers to prevent XSS attacks + const isSvgFile = name && name.toLowerCase().endsWith('.svg'); + if (isSvgFile) { + // For SVG files, add strict CSP to prevent script execution + http.response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'none'; object-src 'none';"); + http.response.setHeader('X-Content-Type-Options', 'nosniff'); + http.response.setHeader('X-Frame-Options', 'DENY'); + } }; /** will initiate download, if links are called with ?download="true" queryparam */ const getContentDisposition = (name, downloadFlag) => { - const dispositionType = downloadFlag === 'true' ? 'attachment;' : 'inline;'; + // Force attachment disposition for SVG files to prevent XSS attacks + const isSvgFile = name && name.toLowerCase().endsWith('.svg'); + const forceAttachment = isSvgFile || downloadFlag === 'true'; + const dispositionType = forceAttachment ? 'attachment;' : 'inline;'; const encodedName = encodeURIComponent(name); const dispositionName = `filename="${encodedName}"; filename=*UTF-8"${encodedName}";`; diff --git a/server/routes/legacyAttachments.js b/server/routes/legacyAttachments.js index 111546c66..a9660efc6 100644 --- a/server/routes/legacyAttachments.js +++ b/server/routes/legacyAttachments.js @@ -53,7 +53,18 @@ if (Meteor.isServer) { // Set appropriate headers res.setHeader('Content-Type', attachment.type || 'application/octet-stream'); res.setHeader('Content-Length', attachment.size || 0); - res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`); + + // Force attachment disposition for SVG files to prevent XSS attacks + const isSvgFile = attachment.name && attachment.name.toLowerCase().endsWith('.svg'); + const disposition = isSvgFile ? 'attachment' : 'attachment'; // Always use attachment for legacy files + res.setHeader('Content-Disposition', `${disposition}; filename="${attachment.name}"`); + + // Add security headers for SVG files + if (isSvgFile) { + res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'none'; object-src 'none';"); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + } // Get GridFS stream for legacy attachment const fileStream = getOldAttachmentStream(attachmentId);