From 5ba9d7e5030e950691adb0578772e06e7780c9c6 Mon Sep 17 00:00:00 2001 From: Ylian Saint-Hilaire Date: Sun, 3 Jul 2022 00:44:58 -0700 Subject: [PATCH] Added support to HTTP web relay on the main web server port with used with a specified DNS name, #4210 --- apprelays.js | 2 +- meshcentral-config-schema.json | 8 +- meshcentral.js | 2 +- sample-config-advanced.json | 2 + views/default.handlebars | 2 + webserver.js | 136 +++++++++++++++++++++++++++------ 6 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apprelays.js b/apprelays.js index aa6e7bb2..8890c306 100644 --- a/apprelays.js +++ b/apprelays.js @@ -220,7 +220,7 @@ module.exports.CreateWebRelay = function (parent, db, args, domain) { // Construct the HTTP request var request = req.method + ' ' + req.url + ' HTTP/' + req.httpVersion + '\r\n'; - const blockedHeaders = ['origin', 'cookie']; // These are headers we do not forward + const blockedHeaders = ['origin', 'cookie', 'upgrade-insecure-requests', 'sec-ch-ua', 'sec-ch-ua-mobile', 'dnt', 'sec-fetch-user', 'sec-ch-ua-platform', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest']; // These are headers we do not forward for (var i in req.headers) { if (blockedHeaders.indexOf(i) == -1) { request += i + ': ' + req.headers[i] + '\r\n'; } } var cookieStr = ''; for (var i in parent.webCookies) { if (cookieStr != '') { cookieStr += '; ' } cookieStr += (i + '=' + parent.webCookies[i].value); } diff --git a/meshcentral-config-schema.json b/meshcentral-config-schema.json index 3c4a5ba6..61f0f5ad 100644 --- a/meshcentral-config-schema.json +++ b/meshcentral-config-schema.json @@ -86,12 +86,14 @@ "statsevents": { "type": "integer", "default": 2592000, "description": "Amount of time in seconds that server statistics are kept in the database." } } }, - "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 443 }, "portBind": { "type": "string", "description": "When set, bind the HTTPS main port to a specific network address." }, - "aliasPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, - "redirPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "aliasPort": { "type": "integer", "minimum": 1, "maximum": 65535, "default": null }, + "redirPort": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 80 }, "redirPortBind": { "type": "string", "description": "When set, bind the HTTP redirection port to a specific network address." }, "redirAliasPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "relayPort": { "type": "integer", "minimum": 1, "maximum": 65535, "default": null, "description": "When set, a web relay web server is bound to this port and will allow user access to remote web sites." }, + "relayDNS": { "type": "string", "default": null, "description": "When set, relayPort valie is ignored. Set this to a DNS name the points to this server. When the server is accessed using the DNS name, the main web server port is used as a web relay port." }, "agentPort": { "type": "integer", "minimum": 1, "maximum": 65535, "description": "When set, enabled a new HTTPS server port that only accepts agent connections." }, "agentPortBind": { "type": "string", "description": "When set, binds the agent port to a specific network interface." }, "agentAliasPort": { "type": "integer", "minimum": 1, "maximum": 65535, "description": "When set, indicates the actual publically visible agent-only port. If not set, the AgentPort value is used." }, diff --git a/meshcentral.js b/meshcentral.js index 3d5a0590..a1474d54 100644 --- a/meshcentral.js +++ b/meshcentral.js @@ -1647,7 +1647,7 @@ function CreateMeshCentralServer(config, args) { if (obj.redirserver != null) { obj.redirserver.hookMainWebServer(obj.certificates); } // Start the HTTP relay web server if needed - if ((obj.args.relayport != null) && (obj.args.relayport != 0)) { + if ((typeof obj.args.relaydns != 'string') && (typeof obj.args.relayport == 'number') && (obj.args.relayport != 0)) { obj.webrelayserver = require('./webrelayserver.js').CreateWebRelayServer(obj, obj.db, obj.args, obj.certificates, function () { }); } diff --git a/sample-config-advanced.json b/sample-config-advanced.json index a3cc0f76..238a3e97 100644 --- a/sample-config-advanced.json +++ b/sample-config-advanced.json @@ -29,6 +29,8 @@ "_redirPort": 80, "_redirPortBind": "127.0.0.1", "_redirAliasPort": 80, + "_relayPort": 453, + "_relayDNS": "relay.myserver.mydomain.com", "_agentPort": 1234, "_agentPortBind": "127.0.0.1", "_agentAliasPort": 1234, diff --git a/views/default.handlebars b/views/default.handlebars index 7bfaf286..6c722c92 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -1456,6 +1456,7 @@ var features2 = parseInt('{{{features2}}}'); var sessionTime = parseInt('{{{sessiontime}}}'); var webRelayPort = parseInt('{{{webRelayPort}}}'); + var webRelayDns = '{{{webRelayDns}}}'; var sessionRefreshTimer = null; var domain = '{{{domain}}}'; var domainUrl = '{{{domainurl}}}'; @@ -8117,6 +8118,7 @@ } var servername = serverinfo.name; if ((servername.indexOf('.') == -1) || ((features & 2) != 0)) { servername = window.location.hostname; } // If the server name is not set or it's in LAN-only mode, use the URL hostname as server name. + if (webRelayDns != '') { servername = webRelayDns; } var url = 'https://' + servername + ':' + webRelayPort + '/control-redirect.ashx?n=' + nodeid + '&p=' + port + '&appid=' + protocol; // Protocol: 1 = HTTP, 2 = HTTPS if (addr != null) { url += '&addr=' + addr; } if (relayid != null) { url += '&relayid=' + relayid; } diff --git a/webserver.js b/webserver.js index 70b57ff6..73eac741 100644 --- a/webserver.js +++ b/webserver.js @@ -86,6 +86,11 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.renderLanguages = []; obj.destroyedSessions = {}; + // Web relay sessions + var webRelayNextSessionId = 1; + var webRelaySessions = {} // RelayID --> Web Mutli-Tunnel + var webRelayCleanupTimer = null; + // Mesh Rights const MESHRIGHT_EDITMESH = 0x00000001; const MESHRIGHT_MANAGEUSERS = 0x00000002; @@ -2859,7 +2864,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF webstate: encodeURIComponent(webstate).replace(/'/g, '%27'), amtscanoptions: amtscanoptions, pluginHandler: (parent.pluginHandler == null) ? 'null' : parent.pluginHandler.prepExports(), - webRelayPort: ((parent.webrelayserver != null) ? parent.webrelayserver.port : 0) + webRelayPort: ((typeof args.relaydns == 'string') ? args.port : ((parent.webrelayserver != null) ? parent.webrelayserver.port : 0)), + webRelayDns: ((typeof args.relaydns == 'string') ? args.relaydns : '') }, dbGetFunc.req, domain), user); } xdbGetFunc.req = req; @@ -5725,7 +5731,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } } } - obj.app.use(obj.bodyParser.urlencoded({ extended: false })); + //obj.app.use(obj.bodyParser.urlencoded({ extended: false })); var sessionOptions = { name: 'xid', // Recommended security practice to not use the default cookie name httpOnly: true, @@ -5835,13 +5841,32 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF req.clientIp = ipex; } + // If this is a web relay connection, handle it here. + if ((typeof obj.args.relaydns == 'string') && (req.headers.host == obj.args.relaydns) && (!req.url.startsWith('/control-redirect.ashx?n='))) { + // If this is a normal request (GET, POST, etc) handle it here + if ((req.session.userid != null) && (req.session.rid != null)) { + var relaySession = webRelaySessions[req.session.userid + '/' + req.session.rid]; + if (relaySession != null) { + // The web relay session is valid, use it + relaySession.handleRequest(req, res); + } else { + // No web relay ession with this relay identifier, close the HTTP request. + res.sendStatus(404); + } + } else { + // The user is not logged in or does not have a relay identifier, close the HTTP request. + res.sendStatus(404); + } + return; + } + // Get the domain for this request const domain = req.xdomain = getDomain(req); parent.debug('webrequest', '(' + req.clientIp + ') ' + req.url); // Skip the rest is this is an agent connection if ((req.url.indexOf('/meshrelay.ashx/.websocket') >= 0) || (req.url.indexOf('/agent.ashx/.websocket') >= 0) || (req.url.indexOf('/localrelay.ashx/.websocket') >= 0)) { next(); return; } - + // Setup security headers const geourl = (domain.geolocation ? ' *.openstreetmap.org' : ''); var selfurl = ' wss://' + req.headers.host; @@ -5951,27 +5976,27 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } else { // Present the login page as the root page obj.app.get(url, handleRootRequest); - obj.app.post(url, handleRootPostRequest); + obj.app.post(url, obj.bodyParser.urlencoded({ extended: false }), handleRootPostRequest); } obj.app.get(url + 'refresh.ashx', function (req, res) { res.sendStatus(200); }); if ((domain.myserver !== false) && ((domain.myserver == null) || (domain.myserver.backup === true))) { obj.app.get(url + 'backup.zip', handleBackupRequest); } - if ((domain.myserver !== false) && ((domain.myserver == null) || (domain.myserver.restore === true))) { obj.app.post(url + 'restoreserver.ashx', handleRestoreRequest); } + if ((domain.myserver !== false) && ((domain.myserver == null) || (domain.myserver.restore === true))) { obj.app.post(url + 'restoreserver.ashx', obj.bodyParser.urlencoded({ extended: false }), handleRestoreRequest); } obj.app.get(url + 'terms', handleTermsRequest); obj.app.get(url + 'xterm', handleXTermRequest); obj.app.get(url + 'login', handleRootRequest); - obj.app.post(url + 'login', handleRootPostRequest); - obj.app.post(url + 'tokenlogin', handleLoginRequest); + obj.app.post(url + 'login', obj.bodyParser.urlencoded({ extended: false }), handleRootPostRequest); + obj.app.post(url + 'tokenlogin', obj.bodyParser.urlencoded({ extended: false }), handleLoginRequest); obj.app.get(url + 'logout', handleLogoutRequest); obj.app.get(url + 'MeshServerRootCert.cer', handleRootCertRequest); - obj.app.post(url + 'changepassword', handlePasswordChangeRequest); - obj.app.post(url + 'deleteaccount', handleDeleteAccountRequest); - obj.app.post(url + 'createaccount', handleCreateAccountRequest); - obj.app.post(url + 'resetpassword', handleResetPasswordRequest); - obj.app.post(url + 'resetaccount', handleResetAccountRequest); + obj.app.post(url + 'changepassword', obj.bodyParser.urlencoded({ extended: false }), handlePasswordChangeRequest); + obj.app.post(url + 'deleteaccount', obj.bodyParser.urlencoded({ extended: false }), handleDeleteAccountRequest); + obj.app.post(url + 'createaccount', obj.bodyParser.urlencoded({ extended: false }), handleCreateAccountRequest); + obj.app.post(url + 'resetpassword', obj.bodyParser.urlencoded({ extended: false }), handleResetPasswordRequest); + obj.app.post(url + 'resetaccount', obj.bodyParser.urlencoded({ extended: false }), handleResetAccountRequest); obj.app.get(url + 'checkmail', handleCheckMailRequest); obj.app.get(url + 'agentinvite', handleAgentInviteRequest); obj.app.get(url + 'userimage.ashx', handleUserImageRequest); - obj.app.post(url + 'amtevents.ashx', obj.handleAmtEventRequest); + obj.app.post(url + 'amtevents.ashx', obj.bodyParser.urlencoded({ extended: false }), obj.handleAmtEventRequest); obj.app.get(url + 'meshagents', obj.handleMeshAgentRequest); obj.app.get(url + 'messenger', handleMessengerRequest); obj.app.get(url + 'messenger.png', handleMessengerImageRequest); @@ -5979,10 +6004,10 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.app.get(url + 'meshsettings', obj.handleMeshSettingsRequest); obj.app.get(url + 'devicepowerevents.ashx', obj.handleDevicePowerEvents); obj.app.get(url + 'downloadfile.ashx', handleDownloadFile); - obj.app.post(url + 'uploadfile.ashx', handleUploadFile); - obj.app.post(url + 'uploadfilebatch.ashx', handleUploadFileBatch); - obj.app.post(url + 'uploadmeshcorefile.ashx', handleUploadMeshCoreFile); - obj.app.post(url + 'oneclickrecovery.ashx', handleOneClickRecoveryFile); + 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 + '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); obj.app.ws(url + 'echo.ashx', handleEchoWebSocket); obj.app.ws(url + '2fahold.ashx', handle2faHoldWebSocket); @@ -6013,7 +6038,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.app.get(url + 'agentdownload.ashx', handleAgentDownloadFile); obj.app.get(url + 'logo.png', handleLogoRequest); obj.app.get(url + 'loginlogo.png', handleLoginLogoRequest); - obj.app.post(url + 'translations', handleTranslationsRequest); + obj.app.post(url + 'translations', obj.bodyParser.urlencoded({ extended: false }), handleTranslationsRequest); obj.app.get(url + 'welcome.jpg', handleWelcomeImageRequest); obj.app.get(url + 'welcome.png', handleWelcomeImageRequest); obj.app.get(url + 'recordings.ashx', handleGetRecordings); @@ -6044,11 +6069,11 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } if (domain.agentinvitecodes == true) { obj.app.get(url + 'invite', handleInviteRequest); - obj.app.post(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.handlePluginAdminPostReq); + obj.app.post(url + 'pluginadmin.ashx', obj.bodyParser.urlencoded({ extended: false }), obj.handlePluginAdminPostReq); obj.app.get(url + 'pluginHandler.js', obj.handlePluginJS); } @@ -6100,9 +6125,68 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF }); } + // Setup web relay on this web server if needed + // We set this up when a DNS name is used as a web relay instead of a port + if (typeof obj.args.relaydns == 'string') { + // This is the magic URL that will setup the relay session + obj.app.get('/control-redirect.ashx', function (req, res, next) { + if (req.headers.host != obj.args.relaydns) { res.sendStatus(404); return; } + if ((req.session.userid == null) && obj.args.user && obj.users['user//' + obj.args.user.toLowerCase()]) { req.session.userid = 'user//' + obj.args.user.toLowerCase(); } // Use a default user if needed + if ((req.session == null) || (req.session.userid == null)) { res.redirect('/'); return; } + res.set({ 'Cache-Control': 'no-store' }); + parent.debug('web', 'webRelaySetup'); + + // Check that all the required arguments are present + if ((req.session.userid == null) || (req.query.n == null) || (req.query.p == null) || ((req.query.appid != 1) && (req.query.appid != 2))) { res.redirect('/'); return; } + + // Get the user and domain information + const userid = req.session.userid; + const domainid = userid.split('/')[1]; + const domain = parent.config.domains[domainid]; + const nodeid = ((req.query.relayid != null) ? req.query.relayid : req.query.n); + const addr = (req.query.addr != null) ? req.query.addr : '127.0.0.1'; + const port = parseInt(req.query.p); + const appid = parseInt(req.query.appid); + + // Check to see if we already have a multi-relay session that matches exactly this device and port for this user + var relaySession = null; + for (var i in webRelaySessions) { + const xrelaySession = webRelaySessions[i]; + if ((xrelaySession.domain.id == domain.id) && (xrelaySession.userid == userid) && (xrelaySession.nodeid == nodeid) && (xrelaySession.addr == addr) && (xrelaySession.port == port) && (xrelaySession.appid == appid)) { + relaySession = xrelaySession; // We found an exact match + } + } + + if (relaySession != null) { + // Since we found a match, use it + req.session.rid = relaySession.sessionId; + } else { + // Create a web relay session + relaySession = require('./apprelays.js').CreateWebRelaySession(parent, db, req, args, domain, userid, nodeid, addr, port, appid); + relaySession.onclose = function (sessionId) { + // Remove the relay session + delete webRelaySessions[sessionId]; + // If there are not more relay sessions, clear the cleanup timer + if ((Object.keys(webRelaySessions).length == 0) && (webRelayCleanupTimer != null)) { clearInterval(webRelayCleanupTimer); webRelayCleanupTimer = null; } + } + relaySession.sessionId = webRelayNextSessionId++; + + // Set the multi-tunnel session + webRelaySessions[userid + '/' + relaySession.sessionId] = relaySession; + req.session.rid = relaySession.sessionId; + + // Setup the cleanup timer if needed + if (webRelayCleanupTimer == null) { webRelayCleanupTimer = setInterval(checkWebRelaySessionsTimeout, 10000); } + } + + // Redirect to root + res.redirect('/'); + }); + } + // Setup firebase push only server if ((obj.parent.firebase != null) && (obj.parent.config.firebase)) { - if (obj.parent.config.firebase.pushrelayserver) { parent.debug('email', 'Firebase-pushrelay-handler'); obj.app.post(url + 'firebaserelay.aspx', handleFirebasePushOnlyRelayRequest); } + if (obj.parent.config.firebase.pushrelayserver) { parent.debug('email', 'Firebase-pushrelay-handler'); obj.app.post(url + 'firebaserelay.aspx', obj.bodyParser.urlencoded({ extended: false }), handleFirebasePushOnlyRelayRequest); } if (obj.parent.config.firebase.relayserver) { parent.debug('email', 'Firebase-relay-handler'); obj.app.ws(url + 'firebaserelay.aspx', handleFirebaseRelayRequest); } } @@ -6342,7 +6426,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (domain.passport == null) { next(); return; } domain.passport.authenticate('saml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next); }); - obj.app.post(url + 'auth-saml-callback', function (req, res, next) { + obj.app.post(url + 'auth-saml-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) { var domain = getDomain(req); if (domain.passport == null) { next(); return; } domain.passport.authenticate('saml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next); @@ -6384,7 +6468,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (domain.passport == null) { next(); return; } domain.passport.authenticate('isaml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next); }); - obj.app.post(url + 'auth-intel-callback', function (req, res, next) { + obj.app.post(url + 'auth-intel-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) { var domain = getDomain(req); if (domain.passport == null) { next(); return; } domain.passport.authenticate('isaml-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next); @@ -6423,7 +6507,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (domain.passport == null) { next(); return; } domain.passport.authenticate('jumpcloud-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next); }); - obj.app.post(url + 'auth-jumpcloud-callback', function (req, res, next) { + obj.app.post(url + 'auth-jumpcloud-callback', obj.bodyParser.urlencoded({ extended: false }), function (req, res, next) { var domain = getDomain(req); if (domain.passport == null) { next(); return; } domain.passport.authenticate('jumpcloud-' + domain.id, { failureRedirect: '/', failureFlash: true })(req, res, next); @@ -8263,6 +8347,10 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF return header + value + '\r\n'; } + // Check that everything is cleaned up + function checkWebRelaySessionsTimeout() { + for (var i in webRelaySessions) { webRelaySessions[i].checkTimeout(); } + } // Check that a cookie IP is within the correct range depending on the active policy function checkCookieIp(cookieip, ip) {