This commit is contained in:
Sammy Ndabo 2025-10-23 07:54:32 +01:00 committed by GitHub
commit faa944ea95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 363 additions and 61 deletions

2
.gitignore vendored
View File

@ -311,4 +311,4 @@ __pycache__/
# When running mkdocs locally as dev
docs/__pycache__/
docs/env/
docker-compose.yaml
docker-compose.yaml

View File

@ -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 <table> is relplaced with <div><p> then image can be defined in css
/* Example if <table> is relplaced with <div><p> 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;
} */

View File

@ -598,6 +598,7 @@
</span>
<span id="accountCreateLoginTokenSpan" style="display:none"><a href=# onclick="return account_createLoginToken()">Create login token</a><br /></span>
<a href=# onclick="return account_showThemesSwitcher()">Switch theme</a><br />
<span id="accountCustomIconsSpan" style="display:none"><a href=# onclick="return showIconCustomization()">Icons Customization</a><br /></span>
</p>
<br style=clear:both />
</div>
@ -2804,6 +2805,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));
@ -3822,6 +3824,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(); }
@ -18883,7 +18887,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]); } }
@ -20819,11 +20823,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 = '<p class="text-muted">Upload custom SVG icons or provide a URL/data URL. Uploaded files are stored in the public icons directory.</p>';
for (var i = 0; i < customIconConfig.length; i++) {
var cfg = customIconConfig[i];
var currentValue = customIconValues[cfg.key] || '';
x += '<div class="mb-3">';
x += '<label class="form-label" for="iconInput_' + cfg.key + '">' + EscapeHtml(cfg.label) + '</label>';
x += '<div class="input-group">';
x += '<input type="text" class="form-control" id="iconInput_' + cfg.key + '" value="' + EscapeHtml(currentValue) + '" placeholder="Enter URL or data URL for ' + EscapeHtml(cfg.label) + ' icon" />';
x += '<button class="btn btn-outline-secondary" type="button" onclick="return triggerIconFileInput(\'' + cfg.key + '\')">Upload SVG</button>';
x += '</div>';
x += '<input type="file" class="d-none" accept=".svg,image/svg+xml" id="iconFile_' + cfg.key + '" onchange="handleIconFileChange(\'' + cfg.key + '\', this)" />';
x += '</div>';
}
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 = ''; }
});
</script>
</body>

View File

@ -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('<html><head><meta http-equiv="refresh" content=0;url="' + url + '"></head><body></body></html>');
}
@ -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;