diff --git a/.gitignore b/.gitignore index 58d0278114..9bc3743918 100644 --- a/.gitignore +++ b/.gitignore @@ -311,4 +311,4 @@ __pycache__/ # When running mkdocs locally as dev docs/__pycache__/ docs/env/ -docker-compose.yaml +docker-compose.yaml \ No newline at end of file diff --git a/public/styles/style-bootstrap.css b/public/styles/style-bootstrap.css index c8faec77c0..2827bd5fd0 100644 --- a/public/styles/style-bootstrap.css +++ b/public/styles/style-bootstrap.css @@ -272,7 +272,7 @@ body { padding-top: 5px; cursor: pointer; position: absolute; - right: 0; + right: 0; margin-right: 10px; } @@ -348,7 +348,7 @@ body { -ms-box-sizing:border-box; -moz-box-sizing:border-box; box-sizing:border-box; - -webkit-box-sizing:border-box; + -webkit-box-sizing:border-box; } .night #column_l { @@ -562,7 +562,7 @@ body { padding: 3px; margin-right: 3px; cursor: pointer; - background-color: white; + background-color: white; } #id_dialogtitle { @@ -761,7 +761,7 @@ body { margin-left: 5px; } -/* Example if is relplaced with

then image can be defined in css +/* Example if

is relplaced with

then image can be defined in css #NoMeshesPanel { background: url(../images/info.png) no-repeat 23px 20px; height: 48px; @@ -2075,7 +2075,7 @@ nav .lbbuttonsel2 { #d2notifyMsg, #d2devNotes, #d2devEvent, -#d2runcmd, +#d2runcmd, #d2devMessage, #d2smsText, #d2emailSubject, @@ -2657,7 +2657,7 @@ nav .lbbuttonsel2 { .deskToolsBar:hover { background-color: #EFE8B6; } - + .night .deskToolsBar { color: #ddd; } @@ -3291,7 +3291,7 @@ nav .lbbuttonsel2 { .sidebar .nav-link { font-size: xx-large; } - + .card:hover, #p2AccountImage:hover, #p2canvas:hover { background: #f3f5f7 !important; } @@ -3326,6 +3326,20 @@ nav .lbbuttonsel2 { border: none; } +/* Shared styles for all custom icons */ +.custom-icon svg { + background-repeat: no-repeat; + background-position: center; + background-size: contain; + width: 1em !important; + height: 1em !important; + display: inline-block !important; +} + +.custom-icon svg path { + display: none !important; +} + /* hide .sidebar when on mobile */ @media (max-width: 768px) { #page_leftbar { @@ -3397,11 +3411,11 @@ nav .lbbuttonsel2 { /* .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered { display: inline; } - + .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { display: inline-flex; } - + .select2-container--bootstrap-5 .select2-selection--multiple .select2-search { display: inline !important; } */ \ No newline at end of file diff --git a/views/default3.handlebars b/views/default3.handlebars index c70000ccb9..60ac1bed5b 100644 --- a/views/default3.handlebars +++ b/views/default3.handlebars @@ -597,6 +597,7 @@ Switch theme
+


@@ -2805,6 +2806,7 @@ QV('p2ServerActions', (siteRights & 21) && ((serverFeatures & 143) != 0)); QV('LeftMenuMyServer', (siteRights & 21) && ((serverFeatures & 64) != 0)); // 16 + 4 + 1 QV('MainMenuMyServer', siteRights & 21); + QV('accountCustomIconsSpan', (userinfo.siteadmin === 0xFFFFFFFF)); QV('p2ServerActionsBackup', (siteRights & 1) && ((serverFeatures & 1) != 0)); QV('p2ServerActionsRestore', (siteRights & 4) && ((serverFeatures & 2) != 0)); QV('p2ServerActionsVersion', (siteRights & 16) && ((serverFeatures & 4) != 0)); @@ -3823,6 +3825,8 @@ var webstate = JSON.parse(message.event.state); for (var i in webstate) { localStorage.setItem(i, webstate[i]); } + customIconValues = loadCustomIconState(); + applyIconCustomization(customIconValues); // Update the web page //if ((webstate.deskAspectRatio != null) && (webstate.deskAspectRatio != deskAspectRatio)) { deskAspectRatio = webstate.deskAspectRatio; deskAdjust(); } @@ -18882,7 +18886,7 @@ if (rec.meshname) { x += addHtmlValue4("Device Group", EscapeHtml(rec.meshname)); } if (rec.size) { x += addHtmlValue4("Size", format("{0} bytes", rec.size)); } if (rec.startTime) { x += addHtmlValue4("Start Time", printTime(new Date(rec.startTime))); } - if (rec.startTime && rec.lengthTime) { x += addHtmlValue4("End Time", printTime(new Date(rec.startTime + (rec.lengthTime * 1000)))); } + if (rec.startTime && rec.lengthTime) { x += addHtmlValue4("End Time", printTime(new Date(rec.startTime + (rec.lengthTime * 1000)))); } if (rec.lengthTime) { x += addHtmlValue4("Duration", pad2(Math.floor(rec.lengthTime / 3600)) + ':' + pad2(Math.floor((rec.lengthTime % 3600) / 60)) + ':' + pad2(Math.floor(rec.lengthTime % 60))); } if (rec.multiplex == true) { x += addHtmlValue4("Multiplexor", "Enabled"); } if (rec.userids) { for (var i in rec.userids) { x += addHtmlValue4("User", rec.userids[i].split('/')[2]); } } @@ -20818,11 +20822,152 @@ } } + // --- Icons Customization --- + var customIconValues = {}; + const customIconConfig = [ + { key: 'myDevices', label: 'My Devices', elementId: 'LeftMenuMyDevices' }, + { key: 'myAccount', label: 'My Account', elementId: 'LeftMenuMyAccount' }, + { key: 'myEvents', label: 'My Events', elementId: 'LeftMenuMyEvents' }, + { key: 'myFiles', label: 'My Files', elementId: 'LeftMenuMyFiles' }, + { key: 'myUsers', label: 'My Users', elementId: 'LeftMenuMyUsers' }, + { key: 'myServer', label: 'My Server', elementId: 'LeftMenuMyServer' } + ]; + + function loadCustomIconState() { + var raw = getstore('customIcons', '{}'); + if ((typeof raw !== 'string') || (raw.length === 0)) { return {}; } + try { return JSON.parse(raw); } catch (ex) { return {}; } + } + + function sanitizeCustomIconState(state) { + var sanitized = {}; + if (state == null) { return sanitized; } + for (var i = 0; i < customIconConfig.length; i++) { + var key = customIconConfig[i].key; + var value = state[key]; + if (typeof value === 'string') { + var trimmed = value.trim(); + if (trimmed.length > 0) { sanitized[key] = trimmed; } + } + } + return sanitized; + } + + function persistCustomIconState(state) { + var sanitized = sanitizeCustomIconState(state); + putstore('customIcons', JSON.stringify(sanitized)); + customIconValues = sanitized; + applyIconCustomization(sanitized); + } + + function showIconCustomization() { + customIconValues = loadCustomIconState(); + var x = '

Upload custom SVG icons or provide a URL/data URL. Uploaded files are stored in the public icons directory.

'; + for (var i = 0; i < customIconConfig.length; i++) { + var cfg = customIconConfig[i]; + var currentValue = customIconValues[cfg.key] || ''; + x += '
'; + x += ''; + x += '
'; + x += ''; + x += ''; + x += '
'; + x += ''; + x += '
'; + } + + setModalContent('xxAddAgent', 'Icons Customization', x, 'large'); + showModal('xxAddAgentModal', 'idx_dlgOkButton', saveIconCustomization); + return false; + } + + function triggerIconFileInput(iconKey) { + var fileInput = document.getElementById('iconFile_' + iconKey); + if (fileInput) { fileInput.click(); } + return false; + } + + async function handleIconFileChange(iconKey, input) { + if (!input || !input.files || (input.files.length === 0)) { return; } + try { + var result = await uploadCustomIcon(iconKey, input.files[0]); + if ((result == null) || (result.path == null)) { + messagebox('Icons Customization', 'The server did not return a valid icon path.'); + } else { + customIconValues[iconKey] = result.path; + var textInput = document.getElementById('iconInput_' + iconKey); + if (textInput) { textInput.value = result.path; } + persistCustomIconState(customIconValues); + } + } catch (ex) { + messagebox('Icons Customization', (ex && ex.message) ? ex.message : 'Failed to upload the icon.'); + } finally { + input.value = ''; + } + } + + async function uploadCustomIcon(iconKey, file) { + var formData = new FormData(); + formData.append('iconType', iconKey); + if (customIconValues && typeof customIconValues[iconKey] === 'string' && customIconValues[iconKey].length > 0) { + formData.append('previousIcon', customIconValues[iconKey]); + } + formData.append('iconFile', file); + + var response = await fetch('customiconupload.ashx', { method: 'POST', body: formData, credentials: 'same-origin' }); + if (!response.ok) { + var message = 'Failed to upload the icon.'; + try { + var errorInfo = await response.json(); + if (errorInfo && typeof errorInfo.error === 'string' && errorInfo.error.length > 0) { message = errorInfo.error; } + } catch (ex) { } + throw new Error(message); + } + return response.json(); + } + + function saveIconCustomization() { + var updatedState = {}; + for (var i = 0; i < customIconConfig.length; i++) { + var cfg = customIconConfig[i]; + var input = document.getElementById('iconInput_' + cfg.key); + if (input) { updatedState[cfg.key] = input.value; } + } + persistCustomIconState(updatedState); + if (xxModal) { xxModal.hide(); } + } + + function applyIconCustomization(icons) { + var state = sanitizeCustomIconState(icons); + for (var i = 0; i < customIconConfig.length; i++) { + var cfg = customIconConfig[i]; + var anchor = document.getElementById(cfg.elementId); + if (!anchor) { continue; } + var svg = anchor.querySelector('svg'); + if (!svg) { continue; } + if (state[cfg.key]) { + anchor.classList.add('custom-icon'); + svg.style.backgroundImage = `url(${state[cfg.key]})`; + } else { + anchor.classList.remove('custom-icon'); + svg.style.removeProperty('background-image'); + } + } + } + + document.addEventListener('DOMContentLoaded', function () { + customIconValues = loadCustomIconState(); + applyIconCustomization(customIconValues); + }); + + window.addEventListener('load', function () { + applyIconCustomization(customIconValues); + }); + // Request Confirmation if closing while a desktop, terminal session is active window.addEventListener('beforeunload', function (e) { if (((desktop != null) && (xxcurrentView == 11)) || ((terminal != null) && (xxcurrentView == 12))) { e.preventDefault(); e.returnValue = ''; } }); - diff --git a/webserver.js b/webserver.js index cbab56146e..885213c8f5 100644 --- a/webserver.js +++ b/webserver.js @@ -39,6 +39,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.net = require('net'); obj.tls = require('tls'); obj.path = require('path'); + obj.os = require('os'); obj.bodyParser = require('body-parser'); obj.exphbs = require('express-handlebars'); obj.crypto = require('crypto'); @@ -94,6 +95,37 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.renderLanguages = []; obj.destroyedSessions = {}; // userid/req.session.x --> destroyed session time + const isWindowsPlatform = (obj.os.platform() === 'win32'); + const safeUploadTempRoots = (function () { + const roots = []; + const addRoot = function (p) { + if (typeof p !== 'string') { return; } + var resolved; + try { resolved = obj.path.normalize(obj.path.resolve(p)); } catch (ex) { return; } + if (resolved.length === 0) { return; } + if ((resolved.length > 1) && resolved.endsWith(obj.path.sep)) { resolved = resolved.slice(0, -1); } + const comparison = isWindowsPlatform ? resolved.toLowerCase() : resolved; + const comparisonWithSep = comparison + obj.path.sep; + roots.push({ comparison: comparison, comparisonWithSep: comparisonWithSep }); + }; + addRoot(obj.os.tmpdir()); + if (typeof obj.parent.filespath === 'string') { addRoot(obj.path.join(obj.parent.filespath, 'tmp')); } + return roots; + })(); + function resolveSafeUploadTempPath(tempPath) { + if (typeof tempPath !== 'string') { return null; } + var resolvedPath; + try { resolvedPath = obj.path.normalize(obj.path.resolve(tempPath)); } catch (ex) { return null; } + var comparisonPath = isWindowsPlatform ? resolvedPath.toLowerCase() : resolvedPath; + var comparisonPathNoTrailing = comparisonPath; + if ((comparisonPathNoTrailing.length > 1) && comparisonPathNoTrailing.endsWith(obj.path.sep)) { comparisonPathNoTrailing = comparisonPathNoTrailing.slice(0, -1); } + for (var i = 0; i < safeUploadTempRoots.length; i++) { + var root = safeUploadTempRoots[i]; + if ((comparisonPathNoTrailing === root.comparison) || comparisonPath.startsWith(root.comparisonWithSep)) { return resolvedPath; } + } + return null; + } + // Web relay sessions var webRelayNextSessionId = 1; var webRelaySessions = {} // UserId/SessionId/Host --> Web Relay Session @@ -2863,12 +2895,12 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF res.set('Content-Type', 'text/html'); let url = domain.url; if (Object.keys(req.query).length > 0) { url += "?" + Object.keys(req.query).map(function(key) { return encodeURIComponent(key) + "=" + encodeURIComponent(req.query[key]); }).join("&"); } - + // check for relaystate is set, test against configured server name and accepted query params if(req.body && req.body.RelayState !== undefined){ var relayState = decodeURIComponent(req.body.RelayState); var serverName = (obj.getWebServerName(domain, req)).replaceAll('.','\\.'); - + var regexstr = `(?<=https:\\/\\/(?:.+?\\.)?${serverName}\\/?)` + `.*((?<=([\\?&])gotodevicename=(.{64})|` + `gotonode=(.{64})|` + @@ -2888,13 +2920,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF `webrtc=|` + `hide=|` + `viewmode=(\\d+)(?=[\\&]|\\b)))`; - + var regex = new RegExp(regexstr); if(regex.test(relayState)){ url = relayState; } } - + res.end(''); } @@ -3208,7 +3240,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Get WebRTC configuration var webRtcConfig = null; if (obj.parent.config.settings && obj.parent.config.settings.webrtcconfig && (typeof obj.parent.config.settings.webrtcconfig == 'object')) { webRtcConfig = encodeURIComponent(JSON.stringify(obj.parent.config.settings.webrtcconfig)).replace(/'/g, '%27'); } - else if (args.webrtcconfig && (typeof args.webrtcconfig == 'object')) { webRtcConfig = encodeURIComponent(JSON.stringify(args.webrtcconfig)).replace(/'/g, '%27'); } + else if (args.webrtcconfig && (typeof args.webrtcconfig == 'object')) { webRtcConfig = encodeURIComponent(JSON.stringify(args.webrtcconfig)).replace(/'/g, '%27'); } // Load default page style or new modern ui var uiViewMode = 'default'; @@ -3240,7 +3272,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF passRequirements: passRequirements, customui: customui, webcerthash: Buffer.from(obj.webCertificateFullHashs[domain.id], 'binary').toString('base64').replace(/\+/g, '@').replace(/\//g, '$'), - footer: (domain.footer == null) ? '' : obj.common.replacePlaceholders(domain.footer, { + footer: (domain.footer == null) ? '' : obj.common.replacePlaceholders(domain.footer, { 'serverversion': obj.parent.currentVer, 'servername': obj.getWebServerName(domain, req), 'agentsessions': Object.keys(parent.webserver.wsagents).length, @@ -3493,7 +3525,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF sessiontime: (args.sessiontime) ? args.sessiontime : 60, // Session time in minutes, 60 minutes is the default passRequirements: passRequirements, customui: customui, - footer: (domain.loginfooter == null) ? '' : obj.common.replacePlaceholders(domain.loginfooter, { + footer: (domain.loginfooter == null) ? '' : obj.common.replacePlaceholders(domain.loginfooter, { 'serverversion': obj.parent.currentVer, 'servername': obj.getWebServerName(domain, req), 'agentsessions': Object.keys(parent.webserver.wsagents).length, @@ -3506,8 +3538,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF messageid: msgid, flashErrors: JSON.stringify(flashErrors), passhint: passhint, - - welcometext: domain.welcometext ? encodeURIComponent(obj.common.replacePlaceholders(domain.welcometext, { + + welcometext: domain.welcometext ? encodeURIComponent(obj.common.replacePlaceholders(domain.welcometext, { 'serverversion': obj.parent.currentVer, 'servername': obj.getWebServerName(domain, req), 'agentsessions': Object.keys(parent.webserver.wsagents).length, @@ -4478,11 +4510,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if ((node == null) || (rights != 0xFFFFFFFF) || (visible == false)) { res.sendStatus(404); return; } // We don't have remote control rights to this device for (var i in files.files) { var file = files.files[i]; - obj.fs.readFile(file.path, 'utf8', function (err, data) { + const uploadTempPath = resolveSafeUploadTempPath(file.path); + if (uploadTempPath == null) { res.sendStatus(400); return; } + obj.fs.readFile(uploadTempPath, 'utf8', function (err, data) { if (err != null) return; data = obj.common.IntToStr(0) + data; // Add the 4 bytes encoding type & flags (Set to 0 for raw) obj.sendMeshAgentCore(user, domain, fields.attrib[0], 'custom', data); // Upload the core - try { obj.fs.unlinkSync(file.path); } catch (e) { } + try { obj.fs.unlinkSync(uploadTempPath); } catch (e) { } }); } res.send(''); @@ -4521,11 +4555,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if ((node == null) || (rights != 0xFFFFFFFF) || (visible == false)) { res.sendStatus(404); return; } // We don't have remote control rights to this device for (var i in files.files) { var file = files.files[i]; + const uploadTempPath = resolveSafeUploadTempPath(file.path); + if (uploadTempPath == null) { res.sendStatus(400); return; } // Event Intel AMT One Click Recovery, this will cause Intel AMT wake operations on this and other servers. - parent.DispatchEvent('*', obj, { action: 'oneclickrecovery', userid: user._id, username: user.name, nodeids: [node._id], domain: domain.id, nolog: 1, file: file.path }); + parent.DispatchEvent('*', obj, { action: 'oneclickrecovery', userid: user._id, username: user.name, nodeids: [node._id], domain: domain.id, nolog: 1, file: uploadTempPath }); - //try { obj.fs.unlinkSync(file.path); } catch (e) { } // TODO: Remove this file after 30 minutes. + //try { obj.fs.unlinkSync(uploadTempPath); } catch (e) { } // TODO: Remove this file after 30 minutes. } res.send(''); }); @@ -4533,6 +4569,99 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } // Upload a file to the server + function handleCustomIconUpload(req, res) { + const domain = checkUserIpAddress(req, res); + if (domain == null) { return; } + if ((req.session == null) || (typeof req.session.userid !== 'string')) { res.sendStatus(401); return; } + const user = obj.users[req.session.userid]; + if (user == null) { res.sendStatus(401); return; } + if (user.siteadmin !== SITERIGHT_ADMIN) { res.sendStatus(401); return; } + + const multiparty = require('multiparty'); + const form = new multiparty.Form(); + form.parse(req, function (err, fields, files) { + if (err) { res.status(400).json({ success: false, error: 'Invalid form submission.' }); return; } + + const allowedTypes = { myDevices: 1, myAccount: 1, myEvents: 1, myFiles: 1, myUsers: 1, myServer: 1 }; + const iconType = (fields && fields.iconType && fields.iconType[0]) ? fields.iconType[0] : null; + if ((typeof iconType !== 'string') || (allowedTypes[iconType] !== 1)) { res.status(400).json({ success: false, error: 'Invalid icon type.' }); return; } + + const iconFile = (files && files.iconFile && files.iconFile[0]) ? files.iconFile[0] : null; + if ((iconFile == null) || (typeof iconFile.path !== 'string')) { res.status(400).json({ success: false, error: 'Missing icon file.' }); return; } + const iconTempPath = resolveSafeUploadTempPath(iconFile.path); + if (iconTempPath == null) { res.status(400).json({ success: false, error: 'Invalid icon file location.' }); return; } + + const cleanupTempFile = function () { try { obj.fs.unlink(iconTempPath, function () { }); } catch (ex) { } }; + + const extension = obj.path.extname(iconFile.originalFilename || '').toLowerCase(); + if (extension !== '.svg') { cleanupTempFile(); res.status(400).json({ success: false, error: 'Only SVG files are supported.' }); return; } + + const iconsRoot = obj.path.join(obj.parent.datapath, 'icons'); + const customDir = obj.path.join(iconsRoot, 'custom'); + try { obj.fs.mkdirSync(iconsRoot); } catch (ex) { if (ex.code !== 'EEXIST') { cleanupTempFile(); res.status(500).json({ success: false, error: 'Unable to prepare icons directory.' }); return; } } + try { obj.fs.mkdirSync(customDir); } catch (ex) { if (ex.code !== 'EEXIST') { cleanupTempFile(); res.status(500).json({ success: false, error: 'Unable to prepare icons directory.' }); return; } } + + const resolveExistingIconName = function (requestPath) { + if (typeof requestPath !== 'string') { return null; } + if (requestPath.startsWith('http://') || requestPath.startsWith('https://') || requestPath.startsWith('data:')) { return null; } + if (requestPath.startsWith('/icons/custom/') === false) { return null; } + const name = requestPath.substring('/icons/custom/'.length); + if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1) { return null; } + if (obj.common.IsFilenameValid(name) !== true) { return null; } + if (name.toLowerCase().endsWith('.svg') === false) { return null; } + return name; + }; + + const previousIcon = (fields && fields.previousIcon && fields.previousIcon[0]) ? fields.previousIcon[0] : null; + const previousName = resolveExistingIconName(previousIcon); + if (previousName != null) { + try { obj.fs.unlinkSync(obj.path.join(customDir, previousName)); } catch (ex) { } + } + + const newFilename = iconType + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8) + '.svg'; + const destinationPath = obj.path.join(customDir, newFilename); + + const respondSuccess = function () { res.json({ success: true, path: '/icons/custom/' + newFilename }); }; + + obj.fs.rename(iconTempPath, destinationPath, function (renameErr) { + if (renameErr == null) { respondSuccess(); return; } + if ((renameErr != null) && (renameErr.code === 'EXDEV')) { + obj.common.copyFile(iconTempPath, destinationPath, function (copyErr) { + cleanupTempFile(); + if (copyErr) { res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' }); return; } + respondSuccess(); + }); + } else { + cleanupTempFile(); + res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' }); + } + }); + }); + } + + function handleCustomIconDownload(req, res) { + const domain = getDomain(req); + if (domain == null) { res.sendStatus(404); return; } + + if ((req.params == null) || (typeof req.params[0] !== 'string')) { res.sendStatus(404); return; } + const iconName = req.params[0]; + if ((iconName.length === 0) || (iconName.indexOf('/') !== -1) || (iconName.indexOf('\\') !== -1)) { res.sendStatus(404); return; } + if (obj.common.IsFilenameValid(iconName) !== true) { res.sendStatus(404); return; } + if (iconName.toLowerCase().endsWith('.svg') === false) { res.sendStatus(404); return; } + + const customDir = obj.path.join(obj.parent.datapath, 'icons', 'custom'); + var resolvedPath; + try { resolvedPath = obj.path.normalize(obj.path.join(customDir, iconName)); } catch (ex) { res.sendStatus(404); return; } + const customDirNormalized = obj.path.normalize(customDir + obj.path.sep); + if ((resolvedPath !== obj.path.normalize(customDir)) && (resolvedPath.startsWith(customDirNormalized) === false)) { res.sendStatus(404); return; } + + obj.fs.readFile(resolvedPath, function (err, data) { + if (err) { res.sendStatus(404); return; } + res.set({ 'Content-Type': 'image/svg+xml' }); + res.send(data); + }); + } + function handleUploadFile(req, res) { const domain = checkUserIpAddress(req, res); if (domain == null) { return; } @@ -4574,7 +4703,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var names = fields.name[0].split('*'), sizes = fields.size[0].split('*'), types = fields.type[0].split('*'), datas = fields.data[0].split('*'); if ((names.length == sizes.length) && (types.length == datas.length) && (names.length == types.length)) { for (var i = 0; i < names.length; i++) { - if (obj.common.IsFilenameValid(names[i]) == false) { res.sendStatus(404); return; } + var originalName = names[i]; + var safeName = obj.path.basename(originalName); + if ((safeName !== originalName) || (obj.common.IsFilenameValid(safeName) == false)) { res.sendStatus(404); return; } var filedata = Buffer.from(datas[i].split(',')[1], 'base64'); if ((xfile.quota == null) || ((totalsize + filedata.length) < xfile.quota)) { // Check if quota would not be broken if we add this file // Create the user folder if needed @@ -4585,7 +4716,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.parent.DispatchEvent([user._id], obj, 'updatefiles'); // Fire an event causing this user to update this files }); }); - })(xfile.fullpath, names[i], filedata); + })(xfile.fullpath, safeName, filedata); } else { // Send a notification obj.parent.DispatchEvent([user._id], obj, { action: 'notify', title: "Disk quota exceed", value: names[i], nolog: 1, id: Math.random() }); @@ -4595,8 +4726,14 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } else { // More typical upload method, the file data is in a multipart mime post. for (var i in files.files) { - var file = files.files[i], fpath = obj.path.join(xfile.fullpath, file.originalFilename); - if (obj.common.IsFilenameValid(file.originalFilename) && ((xfile.quota == null) || ((totalsize + file.size) < xfile.quota))) { // Check if quota would not be broken if we add this file + var file = files.files[i]; + var originalFilename = (typeof file.originalFilename === 'string') ? file.originalFilename : ''; + var safeOriginalFilename = obj.path.basename(originalFilename); + var isFilenameAcceptable = (safeOriginalFilename === originalFilename) && obj.common.IsFilenameValid(safeOriginalFilename); + const uploadTempPath = resolveSafeUploadTempPath(file.path); + if (uploadTempPath == null) { res.sendStatus(400); return; } + if (isFilenameAcceptable && ((xfile.quota == null) || ((totalsize + file.size) < xfile.quota))) { // Check if quota would not be broken if we add this file + var fpath = obj.path.join(xfile.fullpath, safeOriginalFilename); // See if we need to create the folder var domainx = 'domain'; @@ -4606,11 +4743,11 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF try { obj.fs.mkdirSync(xfile.fullpath); } catch (e) { } // Rename the file - obj.fs.rename(file.path, fpath, function (err) { + obj.fs.rename(uploadTempPath, fpath, function (err) { if (err && (err.code === 'EXDEV')) { // On some Linux, the rename will fail with a "EXDEV" error, do a copy+unlink instead. - obj.common.copyFile(file.path, fpath, function (err) { - obj.fs.unlink(file.path, function (err) { + obj.common.copyFile(uploadTempPath, fpath, function (err) { + obj.fs.unlink(uploadTempPath, function (err) { obj.parent.DispatchEvent([user._id], obj, 'updatefiles'); // Fire an event causing this user to update this files }); }); @@ -4621,7 +4758,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } else { // Send a notification obj.parent.DispatchEvent([user._id], obj, { action: 'notify', title: "Disk quota exceed", value: file.originalFilename, nolog: 1, id: Math.random() }); - try { obj.fs.unlink(file.path, function (err) { }); } catch (e) { } + try { obj.fs.unlink(uploadTempPath, function (err) { }); } catch (e) { } } } } @@ -4676,13 +4813,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // More typical upload method, the file data is in a multipart mime post. for (var i in files.files) { - var file = files.files[i], ftarget = getRandomPassword() + '-' + file.originalFilename, fpath = obj.path.join(serverpath, ftarget); + var file = files.files[i]; + const ftarget = getRandomPassword() + '-' + file.originalFilename; + const targetPath = obj.path.join(serverpath, ftarget); + const uploadTempPath = resolveSafeUploadTempPath(file.path); + if (uploadTempPath == null) { res.sendStatus(400); return; } cmd.files.push({ name: file.originalFilename, target: ftarget }); // Rename the file - obj.fs.rename(file.path, fpath, function (err) { + obj.fs.rename(uploadTempPath, targetPath, function (err) { if (err && (err.code === 'EXDEV')) { // On some Linux, the rename will fail with a "EXDEV" error, do a copy+unlink instead. - obj.common.copyFile(file.path, fpath, function (err) { obj.fs.unlink(file.path, function (err) { }); }); + obj.common.copyFile(uploadTempPath, targetPath, function (err) { obj.fs.unlink(uploadTempPath, function (err) { }); }); } }); } @@ -5022,9 +5163,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } // Close the recording file - if (ws.logfile != null) { + if (ws.logfile != null) { setTimeout(function(){ // wait 5 seconds before finishing file for some reason? - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { obj.fs.close(logfile.fd); parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename); // Compute session length @@ -5076,7 +5217,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Close the recording file if (ws.logfile != null) { setTimeout(function(){ // wait 5 seconds before finishing file for some reason? - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { obj.fs.close(logfile.fd); parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename); // Compute session length @@ -5152,7 +5293,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Close the recording file if (ws.logfile != null) { setTimeout(function(){ // wait 5 seconds before finishing file for some reason? - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { obj.fs.close(logfile.fd); parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename); // Compute session length @@ -5194,7 +5335,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Close the recording file if (ws.logfile != null) { setTimeout(function(){ // wait 5 seconds before finishing file for some reason? - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) { obj.fs.close(logfile.fd); parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename); // Compute session length @@ -5297,7 +5438,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (req.query.p == 2) { // Only log event if Intel Redirection, otherwise hundreds of logs for WSMAN are recorded var msg = 'Started relay session', msgid = 13, ip = ((ciraconn != null) ? ciraconn.remoteAddr : (((conn & 4) != 0) ? node.host : req.clientIp)); var event = { etype: 'relay', action: 'relaylog', domain: domain.id, userid: user._id, username: user.name, msgid: msgid, msgArgs: [ws.id, req.clientIp, ip], msg: msg + ' \"' + ws.id + '\" from ' + req.clientIp + ' to ' + ip, protocol: 101, nodeid: node._id }; - obj.parent.DispatchEvent(['*', user._id], obj, event); + obj.parent.DispatchEvent(['*', user._id], obj, event); } // Update user last access time @@ -5610,7 +5751,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if ((user == null) || ((user.siteadmin & 1) == 0)) { res.sendStatus(401); return; } // Check if we have server backup rights // Require modules - const archive = require('archiver')('zip', { level: 9 }); // Sets the compression method to maximum. + const archive = require('archiver')('zip', { level: 9 }); // Sets the compression method to maximum. // Good practice to catch this error explicitly archive.on('error', function (err) { throw err; }); @@ -5618,13 +5759,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Set the archive name res.attachment((domain.title ? domain.title : 'MeshCentral') + '-Backup-' + new Date().toLocaleDateString().replace('/', '-').replace('/', '-') + '.zip'); - // Pipe archive data to the file + // Pipe archive data to the file archive.pipe(res); // Append files from a glob pattern archive.directory(obj.parent.datapath, false); - // Finalize the archive (ie we are done appending files but streams have to finish yet) + // Finalize the archive (ie we are done appending files but streams have to finish yet) archive.finalize(); } @@ -6780,14 +6921,14 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF extraFrameSrc = ' https://' + req.headers.host + ':' + parent.webrelayserver.port; if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { extraFrameSrc += ' https://' + xforwardedhost + ':' + parent.webrelayserver.port; } } - + // If using duo add apihostname to CSP var duoSrc = ''; if ((typeof domain.duo2factor == 'object') && (typeof domain.duo2factor.apihostname == 'string')) { duoSrc = domain.duo2factor.apihostname; } - + // Finish setup security headers const headers = { 'Referrer-Policy': 'no-referrer', @@ -6944,6 +7085,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.app.get(url + 'commander.ashx', handleMeshCommander); obj.app.post(url + 'uploadfile.ashx', obj.bodyParser.urlencoded({ extended: false }), handleUploadFile); obj.app.post(url + 'uploadfilebatch.ashx', obj.bodyParser.urlencoded({ extended: false }), handleUploadFileBatch); + obj.app.post(url + 'customiconupload.ashx', handleCustomIconUpload); + obj.app.get(url + 'icons/custom/*', handleCustomIconDownload); obj.app.post(url + 'uploadmeshcorefile.ashx', obj.bodyParser.urlencoded({ extended: false }), handleUploadMeshCoreFile); obj.app.post(url + 'oneclickrecovery.ashx', obj.bodyParser.urlencoded({ extended: false }), handleOneClickRecoveryFile); obj.app.get(url + 'userfiles/*', handleDownloadUserFiles); @@ -7018,7 +7161,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } obj.app.get(url + 'invite', handleInviteRequest); obj.app.post(url + 'invite', obj.bodyParser.urlencoded({ extended: false }), handleInviteRequest); - + if (parent.pluginHandler != null) { obj.app.get(url + 'pluginadmin.ashx', obj.handlePluginAdminReq); obj.app.post(url + 'pluginadmin.ashx', obj.bodyParser.urlencoded({ extended: false }), obj.handlePluginAdminPostReq); @@ -7343,7 +7486,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Notify account 2fa failed login const ua = obj.getUserAgentInfo(req); obj.parent.DispatchEvent(['*', 'server-users', user._id], obj, { action: 'authfail', username: user.name, userid: user._id, domain: domain.id, msg: 'User login attempt with incorrect 2nd factor from ' + req.clientIp, msgid: 108, msgArgs: [req.clientIp, ua.browserStr, ua.osStr] }); - obj.setbad2Fa(req); + obj.setbad2Fa(req); res.redirect(domain.url + getQueryPortion(req)); }); } else { @@ -7682,7 +7825,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF parent.debug('web', '404 Error ' + req.url); var domain = getDomain(req); if ((domain == null) || (domain.auth == 'sspi')) { res.sendStatus(404); return; } - if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL + if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL const cspNonce = obj.crypto.randomBytes(15).toString('base64'); res.set({ 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'nonce-" + cspNonce + "'; img-src 'self'; style-src 'self' 'nonce-" + cspNonce + "';" }); // This page supports very tight CSP policy res.status(404).render(getRenderPage((domain.sitestyle >= 2) ? 'error4042' : 'error404', req, domain), getRenderArgs({ cspNonce: cspNonce }, req, domain)); @@ -7704,7 +7847,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF parent.debug('web', '404 Error ' + req.url); var domain = getDomain(req); if ((domain == null) || (domain.auth == 'sspi')) { res.sendStatus(404); return; } - if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL + if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL if (obj.args.nice404 == false) { res.sendStatus(404); return; } const cspNonce = obj.crypto.randomBytes(15).toString('base64'); res.set({ 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'nonce-" + cspNonce + "'; img-src 'self'; style-src 'self' 'nonce-" + cspNonce + "';" }); // This page supports very tight CSP policy @@ -8138,7 +8281,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (Buffer.isBuffer(data[0])) { data = Buffer.concat(data); data = data.toString(); - } else { // else if (typeof data[0] == 'string') + } else { // else if (typeof data[0] == 'string') data = data.join(); } } catch (err) { @@ -8193,7 +8336,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF return authStrategyFlags; } - // Handle an incoming request as a web relay + // Handle an incoming request as a web relay function handleWebRelayRequest(req, res) { var webRelaySessionId = null; if ((req.session.userid != null) && (req.session.x != null)) { webRelaySessionId = req.session.userid + '/' + req.session.x; } @@ -8213,7 +8356,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } } - // Handle an incoming websocket connection as a web relay + // Handle an incoming websocket connection as a web relay function handleWebRelayWebSocket(ws, req) { var webRelaySessionId = null; if ((req.session.userid != null) && (req.session.x != null)) { webRelaySessionId = req.session.userid + '/' + req.session.x; } @@ -8657,7 +8800,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (emailcheck && (user.email != null) && (!(user._id.split('/')[2].startsWith('~'))) && (user.emailVerified !== true)) { parent.debug('web', 'Invalid login, asking for email validation'); try { ws.send(JSON.stringify({ action: 'close', cause: 'emailvalidation', msg: 'emailvalidationrequired', email2fa: email2fa, email2fasent: true })); ws.close(); } catch (e) { } - } else { + } else { req.session.userid = user._id; req.session.ip = req.clientIp; setSessionRandom(req); @@ -9418,7 +9561,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF xargs.title1 = domain.title1 ? domain.title1 : ''; xargs.title2 = (domain.title1 && domain.title2) ? domain.title2 : ''; } - xargs.title2 = obj.common.replacePlaceholders(xargs.title2, { + xargs.title2 = obj.common.replacePlaceholders(xargs.title2, { 'serverversion': obj.parent.currentVer, 'servername': obj.getWebServerName(domain, req), 'agentsessions': Object.keys(parent.webserver.wsagents).length, @@ -9776,7 +9919,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (ua.browser && ua.browser.name) { ua.browserStr = ua.browser.name; if (ua.browser.version) { ua.browserStr += '/' + ua.browser.version } } if (ua.os && ua.os.name) { ua.osStr = ua.os.name; if (ua.os.version) { ua.osStr += '/' + ua.os.version } } // If the platform is set, use that instead of the OS - if (ua.platform) { + if (ua.platform) { ua.osStr = ua.platform; // Special case for Windows 11 if (ua.platformVersion) { @@ -9790,9 +9933,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } catch (ex) { return { browserStr: browser, osStr: os } } } - // Return the query string portion of the URL, the ? and anything after BUT remove secret keys from authentication providers + // Return the query string portion of the URL, the ? and anything after BUT remove secret keys from authentication providers function getQueryPortion(req) { - var removeKeys = ['duo_code', 'state']; // Keys to remove + var removeKeys = ['duo_code', 'state']; // Keys to remove var s = req.url.indexOf('?'); if (s == -1) { if (req.body && req.body.urlargs) { @@ -9881,7 +10024,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (parent.config.settings.maxinvalidlogin === false) return true; if (typeof ip == 'object') { ip = ip.clientIp; } var splitip = ip.split('.'); - if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first + if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first var cutoffTime = Date.now() - (parent.config.settings.maxinvalidlogin.time * 60000); // Time in minutes var ipTable = obj.badLoginTable[ip]; if (ipTable == null) return true; @@ -9939,7 +10082,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (parent.config.settings.maxinvalid2fa === false) return true; if (typeof ip == 'object') { ip = ip.clientIp; } var splitip = ip.split('.'); - if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first + if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first var cutoffTime = Date.now() - (parent.config.settings.maxinvalid2fa.time * 60000); // Time in minutes var ipTable = obj.bad2faTable[ip]; if (ipTable == null) return true;