From 40bc91b6f3322ac2e44c08dc8393e8885baab363 Mon Sep 17 00:00:00 2001 From: Ylian Saint-Hilaire Date: Thu, 7 Jul 2022 21:51:09 -0700 Subject: [PATCH] Many CrowdSec improvements. --- crowdsec.js | 131 +++++++++++++++++++++++++++++++++++++++++++++++++ meshcentral.js | 5 +- webserver.js | 30 +++++++++-- 3 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 crowdsec.js diff --git a/crowdsec.js b/crowdsec.js new file mode 100644 index 00000000..e9ca6ea2 --- /dev/null +++ b/crowdsec.js @@ -0,0 +1,131 @@ +module.exports.CreateCrowdSecBouncer = function (parent, config) { + const obj = {}; + + // Setup constants + const { getLogger } = require('@crowdsec/express-bouncer/src/nodejs-bouncer/lib/logger'); + const { configure, renderBanWall, testConnectionToCrowdSec, getRemediationForIp } = require('@crowdsec/express-bouncer/src/nodejs-bouncer'); + const applyCaptcha = require('@crowdsec/express-bouncer/src/express-crowdsec-middleware/lib/captcha'); + const { BYPASS_REMEDIATION, CAPTCHA_REMEDIATION, BAN_REMEDIATION } = require('@crowdsec/express-bouncer/src/nodejs-bouncer/lib/constants'); + const svgCaptcha = require('svg-captcha'); + const { renderCaptchaWall } = require('@crowdsec/express-bouncer/src/nodejs-bouncer'); + + // Current captcha state + const currentCaptchaIpList = {}; + + // Set the default values + if (typeof config.userAgent != 'string') { config.userAgent = "CrowdSec Express-NodeJS bouncer/v0.0.1"; } + if (typeof config.timeout != 'number') { config.timeout = 2000; } + if (typeof config.fallbackRemediation != 'number') { config.fallbackRemediation = BAN_REMEDIATION; } + if (typeof config.maxRemediation != 'number') { config.maxRemediation = BAN_REMEDIATION; } + if (typeof config.captchaGenerationCacheDuration != 'number') { config.captchaGenerationCacheDuration = 60 * 1000; } + if (typeof config.captchaResolutionCacheDuration != 'number') { config.captchaResolutionCacheDuration = 30 * 60 * 1000; } + if (typeof config.captchaTexts != 'object') { config.captchaTexts = {}; } + if (typeof config.banTexts != 'object') { config.banTexts = {}; } + if (typeof config.colors != 'object') { config.colors = {}; } + if (typeof config.hideCrowdsecMentions != 'boolean') { config.hideCrowdsecMentions = false; } + if (typeof config.customCss != 'string') { config.customCss = ''; } + if (typeof config.bypass != 'boolean') { config.bypass = false; } + if (typeof config.trustedRangesForIpForwarding != 'object') { config.trustedRangesForIpForwarding = []; } + if (typeof config.customLogger != 'object') { config.customLogger = null; } + if (typeof config.bypassConnectionTest != 'boolean') { config.bypassConnectionTest = false; } + + // Setup the logger + var logger = config.customLogger ? config.customLogger : getLogger(); + + // Configure the bouncer + configure({ + url: config.url, + apiKey: config.apiKey, + userAgent: config.userAgent, + timeout: config.timeout, + fallbackRemediation: config.fallbackRemediation, + maxRemediation: config.maxRemediation, + captchaTexts: config.captchaTexts, + banTexts: config.banTexts, + colors: config.colors, + hideCrowdsecMentions: config.hideCrowdsecMentions, + customCss: config.customCss + }); + + // Test connectivity + obj.testConnectivity = async function() { return (await testConnectionToCrowdSec())['success']; } + + // Process a web request + obj.process = async function (domain, req, res, next) { + try { + const remediation = await getRemediationForIp(req.clientIp); + //console.log('CrowdSec', req.clientIp, remediation, req.url); + switch (remediation) { + case BAN_REMEDIATION: + const banWallTemplate = await renderBanWall(); + res.status(403); + res.send(banWallTemplate); + return true; + case CAPTCHA_REMEDIATION: + if ((currentCaptchaIpList[req.clientIp] == null) || (currentCaptchaIpList[req.clientIp].resolved !== true)) { + var domainCaptchaUrl = ((domain != null) && (domain.id != '') && (domain.dns == null)) ? ('/' + domain.id + '/captcha.ashx') : '/captcha.ashx'; + if (req.url != domainCaptchaUrl) { res.redirect(domainCaptchaUrl); return true; } + } + break; + } + } catch (ex) { } + return false; + } + + // Process a captcha request + obj.applyCaptcha = async function (req, res, next) { + await applyCaptchaEx(req.clientIp, req, res, next, config.captchaGenerationCacheDuration, config.captchaResolutionCacheDuration, logger); + } + + // Process a captcha request + async function applyCaptchaEx(ip, req, res, next, captchaGenerationCacheDuration, captchaResolutionCacheDuration, loggerInstance) { + logger = loggerInstance; + let error = false; + + if (currentCaptchaIpList[ip] == null) { + generateCaptcha(ip, captchaGenerationCacheDuration); + } else { + if (currentCaptchaIpList[ip] && currentCaptchaIpList[ip].resolved) { + logger.debug({ type: 'CAPTCHA_ALREADY_SOLVED', ip }); + next(); + return; + } else { + if (req.body && req.body.crowdsec_captcha) { + if (req.body.refresh === '1') { generateCaptcha(ip, captchaGenerationCacheDuration); } + if (req.body.phrase !== '') { + if (currentCaptchaIpList[ip].text === req.body.phrase) { + currentCaptchaIpList[ip].resolved = true; + setTimeout(function() { if (currentCaptchaIpList[ip]) { delete currentCaptchaIpList[ip]; } }, captchaResolutionCacheDuration); + res.redirect(req.originalUrl); + logger.info({ type: 'CAPTCHA_RESOLUTION', ip, result: true }); + return; + } else { + logger.info({ type: 'CAPTCHA_RESOLUTION', ip, result: false }); + error = true; + } + } + } + } + } + + const captchaWallTemplate = await renderCaptchaWall({ captchaImageTag: currentCaptchaIpList[ip].data, captchaResolutionFormUrl: '', error }); + res.status(401); + res.send(captchaWallTemplate); + }; + + // Generate a CAPTCHA + function generateCaptcha(ip, captchaGenerationCacheDuration) { + const captcha = svgCaptcha.create(); + currentCaptchaIpList[ip] = { + data: captcha.data, + text: captcha.text, + resolved: false, + }; + setTimeout(() => { + if (currentCaptchaIpList[ip]) { delete currentCaptchaIpList[ip]; } + }, captchaGenerationCacheDuration); + logger.debug({ type: "GENERATE_CAPTCHA", ip }); + }; + + return obj; +} diff --git a/meshcentral.js b/meshcentral.js index 83968a05..7d6adcad 100644 --- a/meshcentral.js +++ b/meshcentral.js @@ -1233,10 +1233,7 @@ function CreateMeshCentralServer(config, args) { } // Start CrowdSec bouncer if needed: https://www.crowdsec.net/ - if (typeof obj.args.crowdsec == 'object') { - const expressCrowdsecBouncer = require("@crowdsec/express-bouncer"); - try { obj.crowdsecMiddleware = await expressCrowdsecBouncer(obj.args.crowdsec); } catch (ex) { delete obj.crowdsecMiddleware; } - } + if (typeof obj.args.crowdsec == 'object') { obj.crowdSecBounser = require('./crowdsec.js').CreateCrowdSecBouncer(obj, obj.args.crowdsec); } // Check if self update is allowed. If running as a Windows service, self-update is not possible. if (obj.fs.existsSync(obj.path.join(__dirname, 'daemon'))) { obj.serverSelfWriteAllowed = false; } diff --git a/webserver.js b/webserver.js index 88a26c42..2e9aebe2 100644 --- a/webserver.js +++ b/webserver.js @@ -3196,6 +3196,23 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } } + // Handle Captcha GET + function handleCaptchaGetRequest(req, res) { + const domain = checkUserIpAddress(req, res); + if (domain == null) { return; } + if (parent.crowdSecBounser == null) { res.sendStatus(404); return; } + parent.crowdSecBounser.applyCaptcha(req, res, function () { res.redirect((((domain.id == '') && (domain.dns == null)) ? '/' : ('/' + domain.id))); }); + } + + // Handle Captcha POST + function handleCaptchaPostRequest(req, res) { + if (parent.crowdSecBounser == null) { res.sendStatus(404); return; } + const domain = checkUserIpAddress(req, res); + if (domain == null) { return; } + req.originalUrl = (((domain.id == '') && (domain.dns == null)) ? '/' : ('/' + domain.id)); + parent.crowdSecBounser.applyCaptcha(req, res, function () { res.redirect(req.originalUrl); }); + } + // Render the terms of service. function handleTermsRequest(req, res) { const domain = checkUserIpAddress(req, res); @@ -5714,11 +5731,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.tlsAltServer.on('resumeSession', function (id, cb) { cb(null, tlsSessionStore[id.toString('hex')] || null); }); obj.expressWsAlt = require('express-ws')(obj.agentapp, obj.tlsAltServer, { wsOptions: { perMessageDeflate: (args.wscompression === true) } }); } - if (parent.crowdsecMiddleware != null) { obj.agentapp.use(parent.crowdsecMiddleware); } // Setup CrowdSec bouncer middleware if needed } // Setup middleware - if (parent.crowdsecMiddleware != null) { obj.app.use(parent.crowdsecMiddleware); } // Setup CrowdSec bouncer middleware if needed obj.app.engine('handlebars', obj.exphbs({ defaultLayout: false })); obj.app.set('view engine', 'handlebars'); if (obj.args.trustedproxy) { @@ -5762,7 +5777,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF }); // Add HTTP security headers to all responses - obj.app.use(function (req, res, next) { + obj.app.use(async function (req, res, next) { // Check if a session is destroyed if (typeof req.session.userid == 'string') { if (typeof req.session.x == 'string') { @@ -5905,6 +5920,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF // Extend the session time by forcing a change to the session every minute. if (req.session.userid != null) { req.session.t = Math.floor(Date.now() / 60e3); } else { delete req.session.t; } + // Check CrowdSec Bounser if configured + if ((parent.crowdSecBounser != null) && (req.headers['upgrade'] != 'websocket') && (req.session.userid == null)) { if ((await parent.crowdSecBounser.process(domain, req, res, next)) == true) { return; } } + // Debugging code, this will stop the agent from crashing if two responses are made to the same request. const render = res.render; const send = res.send; @@ -6080,6 +6098,12 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.app.get(url + 'pluginHandler.js', obj.handlePluginJS); } + // Check CrowdSec Bounser if configured + if (parent.crowdSecBounser != null) { + obj.app.get(url + 'captcha.ashx', handleCaptchaGetRequest); + obj.app.post(url + 'captcha.ashx', obj.bodyParser.urlencoded({ extended: false }), handleCaptchaPostRequest); + } + // Setup IP-KVM relay if supported if (domain.ipkvm) { obj.app.ws(url + 'ipkvm.ashx/*', function (ws, req) {