Added 2FA rate limiting, #3393
Ylian Saint-Hilaire committed
Jan 3, 2022 at 13:12 UTC
5121295128c2bb5e6d27cc56a252fcb47735944c
4 files changed
+137
-3
meshcentral-config-schema.json
+11
@@ -240,6 +240,17 @@
240
"coolofftime": { "type": "integer", "default": null, "description": "Additional time in minute that login attempts will be denied once the invalid login limit is reached." }
241
}
242
},
243
+ "maxInvalid2fa": {
244
+ "type": "object",
245
+ "additionalProperties": false,
246
+ "description": "This section described a policy for how many times an IP address is allowed to attempt to perform two-factor authenticaiton (2FA) incorrectly. By default it's 10 times in 10 minutes, but this can be changed here.",
247
+ "properties": {
248
+ "exclude": { "type": "string", "default": null, "description": "Ranges of IP addresses that are not subject to invalid 2FA limitations. For example: 192.168.1.0/24,172.16.0.1"},
249
+ "time": { "type": "integer", "default": 10, "description": "Time in minutes over which the a maximum number of invalid 2FA attempts is allowed from an IP address." },
250
+ "count": { "type": "integer", "default": 10, "description": "Maximum number of invalid 2FA attempts from an IP address in the time period." },
251
+ "coolofftime": { "type": "integer", "default": null, "description": "Additional time in minute that 2FA attempts will be denied once the invalid 2FA limit is reached." }
252
+ }
253
+ },
254
"amtProvisioningServer": {
255
"type": "object",
256
"additionalProperties": false,
meshuser.js
+39
-1
@@ -5370,7 +5370,8 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
5370
'args': [serverUserCommandArgs, ""],
5371
'autobackup': [serverUserCommandAutoBackup, ""],
5372
'backupconfig': [serverUserCommandBackupConfig, ""],
5373
- 'badlogins': [serverUserCommandBadLogins, ""],
5373
+ 'badlogins': [serverUserCommandBadLogins, "Displays or resets the invalid login rate limiting table."],
5374
+ 'bad2fa': [serverUserCommandBad2fa, "Displays or resets the invalid 2FA rate limiting table."],
5375
'certexpire': [serverUserCommandCertExpire, ""],
5376
'certhashes': [serverUserCommandCertHashes, ""],
5377
'closeusersessions': [serverUserCommandCloseUserSessions, "Disconnects all sessions for a specified user."],
@@ -6367,6 +6368,43 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
6368
}
6369
}
6370
6371
+ function serverUserCommandBad2fa(cmdData) {
6372
+ if (parent.parent.config.settings.maxinvalid2fa == false) {
6373
+ cmdData.result = 'Bad 2FA filter is disabled.';
6374
+ } else {
6375
+ if (cmdData.cmdargs['_'] == 'reset') {
6376
+ // Reset bad login table
6377
+ parent.bad2faTable = {};
6378
+ parent.bad2faTableLastClean = 0;
6379
+ cmdData.result = 'Done.';
6380
+ } else if (cmdData.cmdargs['_'] == '') {
6381
+ // Show current bad login table
6382
+ if (typeof parent.parent.config.settings.maxinvalid2fa.coolofftime == 'number') {
6383
+ cmdData.result = "Max is " + parent.parent.config.settings.maxinvalid2fa.count + " bad 2FA(s) in " + parent.parent.config.settings.maxinvalid2fa.time + " minute(s), " + parent.parent.config.settings.maxinvalid2fa.coolofftime + " minute(s) cooloff.\r\n";
6384
+ } else {
6385
+ cmdData.result = "Max is " + parent.parent.config.settings.maxinvalid2fa.count + " bad 2FA(s) in " + parent.parent.config.settings.maxinvalid2fa.time + " minute(s).\r\n";
6386
+ }
6387
+ var bad2faCount = 0;
6388
+ parent.cleanBad2faTable();
6389
+ for (var i in parent.bad2faTable) {
6390
+ bad2faCount++;
6391
+ if (typeof parent.bad2faTable[i] == 'number') {
6392
+ cmdData.result += "Cooloff for " + Math.floor((parent.bad2faTable[i] - Date.now()) / 60000) + " minute(s)\r\n";
6393
+ } else {
6394
+ if (parent.bad2faTable[i].length > 1) {
6395
+ cmdData.result += (i + ' - ' + parent.bad2faTable[i].length + " records\r\n");
6396
+ } else {
6397
+ cmdData.result += (i + ' - ' + parent.bad2faTable[i].length + " record\r\n");
6398
+ }
6399
+ }
6400
+ }
6401
+ if (bad2faCount == 0) { cmdData.result += 'No bad 2FA.'; }
6402
+ } else {
6403
+ cmdData.result = 'Usage: bad2fa [reset]';
6404
+ }
6405
+ }
6406
+ }
6407
+
6408
function serverUserCommandDispatchTable(cmdData) {
6409
for (var i in parent.parent.eventsDispatch) {
6410
cmdData.result += (i + ', ' + parent.parent.eventsDispatch[i].length + '\r\n');
sample-config-advanced.json
+6
@@ -113,6 +113,12 @@
113
"count": 10,
114
"coolofftime": 10
115
},
116
+ "__maxInvalid2fa": "Time in minutes, max amount of bad two-factor authentication from a source IP in the time before 2FA's are rejected.",
117
+ "_maxInvalid2fa": {
118
+ "time": 10,
119
+ "count": 10,
120
+ "coolofftime": 10
121
+ },
122
"watchDog": { "interval": 100, "timeout": 400 },
123
"_AmtProvisioningServer": {
124
"port": 9971,
webserver.js
+81
-2
@@ -1097,6 +1097,16 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
1097
if (result == false) {
1098
var randomWaitTime = 0;
1099
1100
+ // Check if 2FA is allowed for this IP address
1101
+ if (obj.checkAllow2Fa(req) == false) {
1102
+ // Wait and redirect the user
1103
+ setTimeout(function () {
1104
+ req.session.messageid = 114; // IP address blocked, try again later.
1105
+ if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); }
1106
+ }, 2000 + (obj.crypto.randomBytes(2).readUInt16BE(0) % 4095));
1107
+ return;
1108
+ }
1109
+
1110
// 2-step auth is required, but the token is not present or not valid.
1111
if ((req.body.token != null) || (req.body.hwtoken != null)) {
1112
randomWaitTime = 2000 + (obj.crypto.randomBytes(2).readUInt16BE(0) % 4095); // This is a fail, wait a random time. 2 to 6 seconds.
@@ -1105,7 +1115,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
1115
parent.debug('web', 'handleLoginRequest: invalid 2FA token');
1116
const ua = getUserAgentInfo(req);
1117
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] });
1108
- obj.setbadLogin(req);
1118
+ obj.setbad2Fa(req);
1119
} else {
1120
parent.debug('web', 'handleLoginRequest: 2FA token required');
1121
}
@@ -1599,6 +1609,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
1609
checkUserOneTimePassword(req, domain, user, req.body.token, req.body.hwtoken, function (result) {
1610
if (result == false) {
1611
if (i == 0) {
1612
+
1613
+ // Check if 2FA is allowed for this IP address
1614
+ if (obj.checkAllow2Fa(req) == false) {
1615
+ // Wait and redirect the user
1616
+ setTimeout(function () {
1617
+ req.session.messageid = 114; // IP address blocked, try again later.
1618
+ if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); }
1619
+ }, 2000 + (obj.crypto.randomBytes(2).readUInt16BE(0) % 4095));
1620
+ return;
1621
+ }
1622
+
1623
// 2-step auth is required, but the token is not present or not valid.
1624
parent.debug('web', 'handleResetAccountRequest: Invalid 2FA token, try again');
1625
if ((req.body.token != null) || (req.body.hwtoken != null)) {
@@ -1614,7 +1635,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
1635
req.session.messageid = 108; // Invalid token, try again.
1636
const ua = getUserAgentInfo(req);
1637
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] });
1617
- obj.setbadLogin(req);
1638
+ obj.setbad2Fa(req);
1639
}
1640
}
1641
req.session.loginmode = 5;
@@ -7828,6 +7849,64 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7849
obj.badLoginTableLastClean = 0;
7850
}
7851
7852
+ // This is the invalid 2FA throttling code
7853
+ obj.bad2faTable = {};
7854
+ obj.bad2faTableLastClean = 0;
7855
+ if (parent.config.settings == null) { parent.config.settings = {}; }
7856
+ if (parent.config.settings.maxinvalid2fa !== false) {
7857
+ if (typeof parent.config.settings.maxinvalid2fa != 'object') { parent.config.settings.maxinvalid2fa = { time: 10, count: 10 }; }
7858
+ if (typeof parent.config.settings.maxinvalid2fa.time != 'number') { parent.config.settings.maxinvalid2fa.time = 10; }
7859
+ if (typeof parent.config.settings.maxinvalid2fa.count != 'number') { parent.config.settings.maxinvalid2fa.count = 10; }
7860
+ if ((typeof parent.config.settings.maxinvalid2fa.coolofftime != 'number') || (parent.config.settings.maxinvalid2fa.coolofftime < 1)) { parent.config.settings.maxinvalid2fa.coolofftime = null; }
7861
+ }
7862
+ obj.setbad2Fa = function (ip) { // Set an IP address that just did a bad 2FA request
7863
+ if (parent.config.settings.maxinvalid2fa === false) return;
7864
+ if (typeof ip == 'object') { ip = ip.clientIp; }
7865
+ if (parent.config.settings.maxinvalid2fa != null) {
7866
+ if (typeof parent.config.settings.maxinvalid2fa.exclude == 'string') {
7867
+ const excludeSplit = parent.config.settings.maxinvalid2fa.exclude.split(',');
7868
+ for (var i in excludeSplit) { if (require('ipcheck').match(ip, excludeSplit[i])) return; }
7869
+ } else if (Array.isArray(parent.config.settings.maxinvalid2fa.exclude)) {
7870
+ for (var i in parent.config.settings.maxinvalid2fa.exclude) { if (require('ipcheck').match(ip, parent.config.settings.maxinvalid2fa.exclude[i])) return; }
7871
+ }
7872
+ }
7873
+ var splitip = ip.split('.');
7874
+ if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); }
7875
+ if (++obj.bad2faTableLastClean > 100) { obj.cleanBad2faTable(); }
7876
+ if (typeof obj.bad2faTable[ip] == 'number') { if (obj.bad2faTable[ip] < Date.now()) { delete obj.bad2faTable[ip]; } else { return; } } // Check cooloff period
7877
+ if (obj.bad2faTable[ip] == null) { obj.bad2faTable[ip] = [Date.now()]; } else { obj.bad2faTable[ip].push(Date.now()); }
7878
+ if ((obj.bad2faTable[ip].length >= parent.config.settings.maxinvalid2fa.count) && (parent.config.settings.maxinvalid2fa.coolofftime != null)) {
7879
+ obj.bad2faTable[ip] = Date.now() + (parent.config.settings.maxinvalid2fa.coolofftime * 60000); // Move to cooloff period
7880
+ }
7881
+ }
7882
+ obj.checkAllow2Fa = function (ip) { // Check if an IP address is allowed to perform 2FA
7883
+ if (parent.config.settings.maxinvalid2fa === false) return true;
7884
+ if (typeof ip == 'object') { ip = ip.clientIp; }
7885
+ var splitip = ip.split('.');
7886
+ if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first
7887
+ var cutoffTime = Date.now() - (parent.config.settings.maxinvalid2fa.time * 60000); // Time in minutes
7888
+ var ipTable = obj.bad2faTable[ip];
7889
+ if (ipTable == null) return true;
7890
+ if (typeof ipTable == 'number') { if (obj.bad2faTable[ip] < Date.now()) { delete obj.bad2faTable[ip]; } else { return false; } } // Check cooloff period
7891
+ while ((ipTable.length > 0) && (ipTable[0] < cutoffTime)) { ipTable.shift(); }
7892
+ if (ipTable.length == 0) { delete obj.bad2faTable[ip]; return true; }
7893
+ return (ipTable.length < parent.config.settings.maxinvalid2fa.count); // No more than x bad 2FAs in x minutes
7894
+ }
7895
+ obj.cleanBad2faTable = function () { // Clean up the IP address 2FA blockage table, we do this occasionaly.
7896
+ if (parent.config.settings.maxinvalid2fa === false) return;
7897
+ var cutoffTime = Date.now() - (parent.config.settings.maxinvalid2fa.time * 60000); // Time in minutes
7898
+ for (var ip in obj.bad2faTable) {
7899
+ var ipTable = obj.bad2faTable[ip];
7900
+ if (typeof ipTable == 'number') {
7901
+ if (obj.bad2faTable[ip] < Date.now()) { delete obj.bad2faTable[ip]; } // Check cooloff period
7902
+ } else {
7903
+ while ((ipTable.length > 0) && (ipTable[0] < cutoffTime)) { ipTable.shift(); }
7904
+ if (ipTable.length == 0) { delete obj.bad2faTable[ip]; }
7905
+ }
7906
+ }
7907
+ obj.bad2faTableLastClean = 0;
7908
+ }
7909
+
7910
// Hold a websocket until additional arguments are provided within the socket.
7911
// This is a generic function that can be used for any websocket to avoid passing arguments in the URL.
7912
function getWebsocketArgs(ws, req, func) {