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
}