fix: sanitize custom icon uploads (#7830)

Co-authored-by: Sammy Ndabo <sammy.ndabo@evoludata.com>

Sammy Ndabo committed May 26, 2026 at 15:28 UTC d0017b3444f3f5449511d76a63ece1d1c6a90150
3 files changed +206 -25
public/js/ui-components.js
+33 -2
@@ -184,6 +184,9 @@ class ModernCard {
184 // - `normalizePreviewUrl` for domain/path normalization
185 // - `iconKey`, `label`, `currentValue` for per-instance identity and content
186 // The component owns input/file/preview UI; persistence and status updates stay in page logic.
187 +const CUSTOM_ICON_MAX_FILE_SIZE = 10485760;
188 +const CUSTOM_ICON_MAX_DIMENSION = 64;
189 +
190 class IconUploadComponent {
191 constructor(iconKey, container, options = {}) {
192 this.iconKey = iconKey;
@@ -205,6 +208,24 @@ class IconUploadComponent {
208 try { return this.options.normalizePreviewUrl(value); } catch (ex) { return value; }
209 }
210
211 + getImageDimensions(file) {
212 + return new Promise((resolve, reject) => {
213 + // Read image dimensions locally before upload so oversized icons fail fast.
214 + const imageUrl = URL.createObjectURL(file);
215 + const image = new Image();
216 + image.onload = function () {
217 + const dimensions = { width: image.naturalWidth, height: image.naturalHeight };
218 + URL.revokeObjectURL(imageUrl);
219 + resolve(dimensions);
220 + };
221 + image.onerror = function () {
222 + URL.revokeObjectURL(imageUrl);
223 + reject(new Error('Unable to read uploaded icon dimensions.'));
224 + };
225 + image.src = imageUrl;
226 + });
227 + }
228 +
229 render() {
230 const hasIcon = this.options.currentValue.length > 0;
231 const initialPreviewSrc = hasIcon ? this.getPreviewSrc(this.options.currentValue) : '';
@@ -220,6 +241,7 @@ class IconUploadComponent {
241 <i class="fas fa-upload me-2"></i>Upload
242 </button>
243 </div>
244 + <small class="text-muted d-block mb-3">Upload SVG, PNG or JPEG files up to ${CUSTOM_ICON_MAX_FILE_SIZE / 1048576} MB. PNG/JPEG files must be ${CUSTOM_ICON_MAX_DIMENSION} x ${CUSTOM_ICON_MAX_DIMENSION} pixels or smaller.</small>
245
246 <div class="icon-preview-container ${hasIcon ? '' : 'd-none'}" id="preview_container_${this.iconKey}">
247 <small class="text-muted me-2">Preview:</small>
@@ -231,7 +253,7 @@ class IconUploadComponent {
253 </button>
254 </div>
255
234 - <input type="file" class="d-none" accept=".svg,.png,image/svg+xml,image/png"
256 + <input type="file" class="d-none" accept=".svg,.png,.jpg,.jpeg,image/svg+xml,image/png,image/jpeg"
257 id="iconFile_${this.iconKey}"
258 onchange="window.iconUploadComponents['${this.iconKey}'].handleFileUpload(this)" />
259 </div>
@@ -280,14 +302,21 @@ class IconUploadComponent {
302
303 const button = this.container.querySelector('.btn-outline-primary');
304 const originalContent = button.innerHTML;
305 + const file = input.files[0];
306
307 // Show loading state
308 button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Uploading...';
309 button.disabled = true;
310
311 try {
312 + if (!(/^image\/(svg\+xml|png|jpeg)$/i.test(file.type)) && !(/\.(svg|png|jpg|jpeg)$/i.test(file.name || ''))) { throw new Error('Only SVG, PNG and JPEG icon files are supported.'); }
313 + if ((file.size < 4) || (file.size > CUSTOM_ICON_MAX_FILE_SIZE)) { throw new Error('Icon files must be non-empty and ' + (CUSTOM_ICON_MAX_FILE_SIZE / 1048576) + ' MB or smaller.'); }
314 + if (!(/\.(svg)$/i.test(file.name || '') || /^image\/svg\+xml$/i.test(file.type))) {
315 + const dimensions = await this.getImageDimensions(file);
316 + if ((dimensions.width < 1) || (dimensions.height < 1) || (dimensions.width > CUSTOM_ICON_MAX_DIMENSION) || (dimensions.height > CUSTOM_ICON_MAX_DIMENSION)) { throw new Error('PNG/JPEG icon images must be ' + CUSTOM_ICON_MAX_DIMENSION + ' x ' + CUSTOM_ICON_MAX_DIMENSION + ' pixels or smaller.'); }
317 + }
318 if (this.options.onUpload) {
290 - const result = await this.options.onUpload(this.iconKey, input.files[0]);
319 + const result = await this.options.onUpload(this.iconKey, file);
320
321 // Show success state
322 button.innerHTML = '<i class="fas fa-check me-2"></i>Success!';
@@ -316,11 +345,13 @@ class IconUploadComponent {
345 } catch (error) {
346 // Show error state
347 button.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Failed';
348 + button.title = (error && error.message) ? error.message : '';
349 button.classList.remove('btn-outline-primary');
350 button.classList.add('btn-danger');
351
352 setTimeout(() => {
353 button.innerHTML = originalContent;
354 + button.title = '';
355 button.classList.remove('btn-danger');
356 button.classList.add('btn-outline-primary');
357 button.disabled = false;
views/default3.handlebars
+2 -2
@@ -22595,7 +22595,7 @@
22595 x += '</div>';
22596 x += '<div>';
22597 x += '<h5 class="mb-1 fw-semibold">Customize Your Sidebar Icons</h5>';
22598 - x += '<p class="text-muted mb-0">Upload custom SVG/PNG icons or provide URLs to personalize your sidebar interface experience</p>';
22598 + x += '<p class="text-muted mb-0">Upload custom SVG, PNG or JPEG icons up to 10 MB, or provide URLs to personalize your sidebar interface experience</p>';
22599 x += '</div>';
22600 x += '</div>';
22601 x += '</div>';
@@ -22797,7 +22797,7 @@
22797 if (xxModal) { xxModal.hide(); }
22798 }
22799
22800 - // Supports both SVG menu icons and the modern FontAwesome <i> icons.
22800 + // Supports existing SVG menu elements and modern FontAwesome <i> icons.
22801 function applyIconCustomization(icons) {
22802 var state = sanitizeCustomIconState(icons);
22803 for (var i = 0; i < customIconConfig.length; i++) {
webserver.js
+171 -21
@@ -4660,6 +4660,148 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4660 return obj.path.join(obj.parent.datapath, 'icons', 'custom', userKey);
4661 }
4662
4663 + // Maximum accepted custom icon upload size, in bytes.
4664 + const customIconMaxFileSize = 10485760;
4665 + // Maximum accepted width or height for uploaded PNG/JPEG sidebar icons, in pixels.
4666 + const customIconMaxDimension = 64;
4667 + // Image extensions accepted for uploaded custom sidebar icons.
4668 + const customIconAllowedExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg']);
4669 +
4670 + /**
4671 + * Return the HTTP response MIME type for a stored custom icon filename.
4672 + *
4673 + * @param {string} iconName Filename or path segment for the stored custom icon.
4674 + * @returns {string|null} MIME type for supported icons, or null for unsupported extensions.
4675 + */
4676 + function getCustomIconMimeType(iconName) {
4677 + const lower = iconName.toLowerCase();
4678 + if (lower.endsWith('.svg')) { return 'image/svg+xml'; }
4679 + if (lower.endsWith('.png')) { return 'image/png'; }
4680 + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { return 'image/jpeg'; }
4681 + return null;
4682 + }
4683 +
4684 + /**
4685 + * Reject SVG content that can execute script or load external content.
4686 + *
4687 + * @param {string} svgContent Raw UTF-8 SVG content from the uploaded file.
4688 + * @returns {string|null} SVG content safe to store, or null if it is invalid or unsafe.
4689 + */
4690 + function cleanSvg(svgContent) {
4691 + if (typeof svgContent !== 'string') { return null; }
4692 + const cleaned = (svgContent.charCodeAt(0) === 0xFEFF) ? svgContent.substring(1) : svgContent;
4693 + if (cleaned.search(/<svg[\s>]/i) < 0) { return null; }
4694 + if (cleaned.search(/<\s*(script|foreignObject|iframe|object|embed|applet|link|meta)\b/i) >= 0) { return null; }
4695 + if (cleaned.search(/\s+on[a-z0-9_-]+\s*=/i) >= 0) { return null; }
4696 + if (cleaned.search(/\s+(href|xlink:href|src)\s*=\s*(['"]?)\s*(?!#)/i) >= 0) { return null; }
4697 + return cleaned;
4698 + }
4699 +
4700 + /**
4701 + * Check if a JPEG marker is a Start Of Frame marker that contains image dimensions.
4702 + *
4703 + * @param {number} marker JPEG marker byte after the 0xFF prefix.
4704 + * @returns {boolean} True when the marker segment contains width and height fields.
4705 + */
4706 + function isJpegStartOfFrameMarker(marker) {
4707 + return ((marker >= 0xC0) && (marker <= 0xC3)) || ((marker >= 0xC5) && (marker <= 0xC7)) || ((marker >= 0xC9) && (marker <= 0xCB)) || ((marker >= 0xCD) && (marker <= 0xCF));
4708 + }
4709 +
4710 + /**
4711 + * Read JPEG dimensions from header bytes without fully decoding the image.
4712 + *
4713 + * @param {Buffer} data Initial bytes from the uploaded JPEG file.
4714 + * @returns {{width:number,height:number}|null} Parsed dimensions, or null if the JPEG header is invalid or incomplete.
4715 + */
4716 + function getJpegDimensions(data) {
4717 + // JPEG files must start with the SOI marker.
4718 + if ((data.length < 4) || (data[0] !== 0xFF) || (data[1] !== 0xD8)) { return null; }
4719 + // Start scanning after the SOI marker.
4720 + var offset = 2;
4721 + while (offset + 9 < data.length) {
4722 + // Each JPEG segment starts with a marker prefix.
4723 + if (data[offset] !== 0xFF) { return null; }
4724 + // Skip fill bytes before the marker value.
4725 + while ((offset < data.length) && (data[offset] === 0xFF)) { offset++; }
4726 + const marker = data[offset++];
4727 + // SOI/EOI and restart markers do not carry segment lengths.
4728 + if ((marker === 0xD8) || (marker === 0xD9)) { continue; }
4729 + if ((marker >= 0xD0) && (marker <= 0xD7)) { continue; }
4730 + // Remaining markers should include a two-byte segment length.
4731 + if (offset + 2 > data.length) { return null; }
4732 + const segmentLength = data.readUInt16BE(offset);
4733 + if (segmentLength < 2) { return null; }
4734 + if (isJpegStartOfFrameMarker(marker)) {
4735 + // SOF payload layout: precision, height, width.
4736 + if (offset + 7 > data.length) { return null; }
4737 + return { width: data.readUInt16BE(offset + 5), height: data.readUInt16BE(offset + 3) };
4738 + }
4739 + // Move to the next marker segment.
4740 + offset += segmentLength;
4741 + }
4742 + return null;
4743 + }
4744 +
4745 + /**
4746 + * Validate the raster image signature and extract dimensions for supported custom icon formats.
4747 + *
4748 + * @param {Buffer} data Initial bytes from the uploaded icon file.
4749 + * @param {string} extension Lowercase extension from the original uploaded filename.
4750 + * @returns {{width:number,height:number}|null} Parsed raster dimensions, or null if the signature/type is invalid.
4751 + */
4752 + function getCustomIconDimensions(data, extension) {
4753 + // PNG dimensions are fixed in the IHDR chunk at byte offsets 16 and 20.
4754 + if ((extension === '.png') && (data.length >= 24) && (data[0] === 0x89) && (data[1] === 0x50) && (data[2] === 0x4E) && (data[3] === 0x47) && (data[4] === 0x0D) && (data[5] === 0x0A) && (data[6] === 0x1A) && (data[7] === 0x0A)) {
4755 + return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
4756 + }
4757 + // JPEG dimensions are stored in the first SOF marker segment.
4758 + if (((extension === '.jpg') || (extension === '.jpeg')) && (data.length >= 3) && (data[0] === 0xFF) && (data[1] === 0xD8) && (data[2] === 0xFF)) {
4759 + return getJpegDimensions(data);
4760 + }
4761 + return null;
4762 + }
4763 +
4764 + /**
4765 + * Enforce custom icon upload policy before moving the temp file into persistent storage.
4766 + * SVG files are checked for active content; PNG/JPEG files are signature and dimension checked.
4767 + *
4768 + * @param {string} iconTempPath Safe resolved path to the uploaded temp file.
4769 + * @param {string} extension Lowercase extension from the original uploaded filename.
4770 + * @param {function(string|null):void} callback Called with null on success, or a user-safe error message on failure.
4771 + */
4772 + function validateCustomIconFile(iconTempPath, extension, callback) {
4773 + obj.fs.stat(iconTempPath, function (statErr, stats) {
4774 + if (statErr) { callback('Unable to read uploaded icon.'); return; }
4775 + if ((stats == null) || (stats.isFile() !== true)) { callback('Invalid icon file.'); return; }
4776 + // Reject empty and oversized uploads before reading any file content.
4777 + if ((stats.size < 4) || (stats.size > customIconMaxFileSize)) { callback('Icon files must be non-empty and ' + (customIconMaxFileSize / 1048576) + ' MB or smaller.'); return; }
4778 + if (extension === '.svg') {
4779 + obj.fs.readFile(iconTempPath, 'utf8', function (readErr, svgContent) {
4780 + if (readErr) { callback('Unable to read uploaded icon.'); return; }
4781 + const cleanedSvg = cleanSvg(svgContent);
4782 + if (cleanedSvg == null) { callback('Invalid SVG icon file.'); return; }
4783 + obj.fs.writeFile(iconTempPath, cleanedSvg, 'utf8', function (writeErr) {
4784 + callback(writeErr ? 'Unable to clean uploaded SVG icon.' : null);
4785 + });
4786 + });
4787 + return;
4788 + }
4789 + obj.fs.open(iconTempPath, 'r', function (openErr, fd) {
4790 + if (openErr) { callback('Unable to read uploaded icon.'); return; }
4791 + // Reading the first 64 KB is enough for normal PNG headers and JPEG SOF markers.
4792 + const header = Buffer.alloc(Math.min(stats.size, 65536));
4793 + obj.fs.read(fd, header, 0, header.length, 0, function (readErr, bytesRead) {
4794 + obj.fs.close(fd, function () { });
4795 + if (readErr) { callback('Unable to read uploaded icon.'); return; }
4796 + const dimensions = getCustomIconDimensions(header.slice(0, bytesRead), extension);
4797 + if (dimensions == null) { callback('The uploaded icon does not match its file type.'); return; }
4798 + if ((dimensions.width < 1) || (dimensions.height < 1) || (dimensions.width > customIconMaxDimension) || (dimensions.height > customIconMaxDimension)) { callback('Icon images must be ' + customIconMaxDimension + ' x ' + customIconMaxDimension + ' pixels or smaller.'); return; }
4799 + callback(null);
4800 + });
4801 + });
4802 + });
4803 + }
4804 +
4805 function handleCustomIconUpload(req, res) {
4806 const domain = checkUserIpAddress(req, res);
4807 if (domain == null) { return; }
@@ -4668,9 +4810,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4810 if (user == null) { res.sendStatus(401); return; }
4811
4812 const multiparty = require('multiparty');
4671 - const form = new multiparty.Form();
4813 + const form = new multiparty.Form({ maxFilesSize: customIconMaxFileSize });
4814 form.parse(req, function (err, fields, files) {
4673 - if (err) { res.status(400).json({ success: false, error: 'Invalid form submission.' }); return; }
4815 + if (err) { res.status(400).json({ success: false, error: (err.status === 413) ? 'Icon files must be non-empty and ' + (customIconMaxFileSize / 1048576) + ' MB or smaller.' : 'Invalid form submission.' }); return; }
4816
4817 const allowedTypes = { myDevices: 1, myAccount: 1, myEvents: 1, myFiles: 1, myUsers: 1, myServer: 1 };
4818 const iconType = (fields && fields.iconType && fields.iconType[0]) ? fields.iconType[0] : null;
@@ -4684,7 +4826,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4826 const cleanupTempFile = function () { try { obj.fs.unlink(iconTempPath, function () { }); } catch (ex) { } };
4827
4828 const extension = obj.path.extname(iconFile.originalFilename || '').toLowerCase();
4687 - if ((extension !== '.svg') && (extension !== '.png')) { cleanupTempFile(); res.status(400).json({ success: false, error: 'Only SVG and PNG files are supported.' }); return; }
4829 + if (customIconAllowedExtensions.has(extension) === false) { cleanupTempFile(); res.status(400).json({ success: false, error: 'Only SVG, PNG and JPEG icon files are supported.' }); return; }
4830
4831 const iconsRoot = obj.path.join(obj.parent.datapath, 'icons');
4832 const customDir = obj.path.join(iconsRoot, 'custom');
@@ -4697,27 +4839,32 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4839
4840 const previousIcon = (fields && fields.previousIcon && fields.previousIcon[0]) ? fields.previousIcon[0] : null;
4841 const previousInfo = resolveCustomIconPath(previousIcon, user);
4700 - if ((previousInfo != null) && (previousInfo.isOwned === true)) {
4701 - try { obj.fs.unlinkSync(previousInfo.diskPath); } catch (ex) { }
4702 - }
4842
4843 const newFilename = iconType + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8) + extension;
4844 const destinationPath = obj.path.join(userCustomDir, newFilename);
4845
4707 - const respondSuccess = function () { res.json({ success: true, path: domain.url + 'icons/custom/' + userKey + '/' + newFilename }); };
4846 + const respondSuccess = function () {
4847 + if ((previousInfo != null) && (previousInfo.isOwned === true)) {
4848 + try { obj.fs.unlinkSync(previousInfo.diskPath); } catch (ex) { }
4849 + }
4850 + res.json({ success: true, path: domain.url + 'icons/custom/' + userKey + '/' + newFilename });
4851 + };
4852
4709 - obj.fs.rename(iconTempPath, destinationPath, function (renameErr) {
4710 - if (renameErr == null) { respondSuccess(); return; }
4711 - if ((renameErr != null) && (renameErr.code === 'EXDEV')) {
4712 - obj.common.copyFile(iconTempPath, destinationPath, function (copyErr) {
4853 + validateCustomIconFile(iconTempPath, extension, function (validationError) {
4854 + if (validationError != null) { cleanupTempFile(); res.status(400).json({ success: false, error: validationError }); return; }
4855 + obj.fs.rename(iconTempPath, destinationPath, function (renameErr) {
4856 + if (renameErr == null) { respondSuccess(); return; }
4857 + if ((renameErr != null) && (renameErr.code === 'EXDEV')) {
4858 + obj.common.copyFile(iconTempPath, destinationPath, function (copyErr) {
4859 + cleanupTempFile();
4860 + if (copyErr) { res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' }); return; }
4861 + respondSuccess();
4862 + });
4863 + } else {
4864 cleanupTempFile();
4714 - if (copyErr) { res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' }); return; }
4715 - respondSuccess();
4716 - });
4717 - } else {
4718 - cleanupTempFile();
4719 - res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' });
4720 - }
4865 + res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' });
4866 + }
4867 + });
4868 });
4869 });
4870 }
@@ -4751,7 +4898,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4898 }
4899
4900 const lower = iconName.toLowerCase();
4754 - if ((lower.endsWith('.svg') === false) && (lower.endsWith('.png') === false)) { return null; }
4901 + if ((lower.endsWith('.svg') === false) && (lower.endsWith('.png') === false) && (lower.endsWith('.jpg') === false) && (lower.endsWith('.jpeg') === false)) { return null; }
4902 return { ownerKey: ownerKey, iconName: iconName, diskPath: diskPath, isOwned: isOwned, isLegacy: (pathParts.length === 1) };
4903 }
4904
@@ -4783,11 +4930,14 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4930 const iconInfo = resolveCustomIconPath('/icons/custom/' + req.params[0], user);
4931 if (iconInfo == null) { res.sendStatus(404); return; }
4932 if ((iconInfo.isLegacy !== true) && (iconInfo.isOwned !== true)) { res.sendStatus(404); return; }
4786 - const iconNameLower = iconInfo.iconName.toLowerCase();
4933 + const contentType = getCustomIconMimeType(iconInfo.iconName);
4934 + if (contentType == null) { res.sendStatus(404); return; }
4935
4936 obj.fs.readFile(iconInfo.diskPath, function (err, data) {
4937 if (err) { res.sendStatus(404); return; }
4790 - res.set({ 'Content-Type': iconNameLower.endsWith('.png') ? 'image/png' : 'image/svg+xml' });
4938 + const headers = { 'Content-Type': contentType, 'X-Content-Type-Options': 'nosniff' };
4939 + if (contentType === 'image/svg+xml') { headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'"; }
4940 + res.set(headers);
4941 res.send(data);
4942 });
4943 }