diff --git a/package.json b/package.json index 7ee2d14430..37f7911604 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "express-session": "^1.8.2", "express-useragent": "0.2.4", "html-to-text": "2.0.0", + "ip": "1.1.2", "jimp": "0.2.21", "less": "^2.0.0", "logrotate-stream": "^0.2.3", diff --git a/public/language/en_GB/error.json b/public/language/en_GB/error.json index b60c8a2cde..e9a4cbea7d 100644 --- a/public/language/en_GB/error.json +++ b/public/language/en_GB/error.json @@ -34,6 +34,7 @@ "user-banned": "User banned", "user-too-new": "Sorry, you are required to wait %1 second(s) before making your first post", + "blacklisted-ip": "Sorry, your IP address has been banned from this community. If you feel this is in error, please contact an administrator.", "no-category": "Category does not exist", "no-topic": "Topic does not exist", diff --git a/public/less/admin/admin.less b/public/less/admin/admin.less index 930e518412..182b0afccf 100644 --- a/public/less/admin/admin.less +++ b/public/less/admin/admin.less @@ -12,6 +12,7 @@ @import "./manage/tags"; @import "./manage/groups"; @import "./manage/users"; +@import "./manage/blacklist"; @import "./appearance/customise"; @import "./appearance/themes"; @import "./extend/plugins"; diff --git a/public/less/admin/manage/blacklist.less b/public/less/admin/manage/blacklist.less new file mode 100644 index 0000000000..4e0dcb7c27 --- /dev/null +++ b/public/less/admin/manage/blacklist.less @@ -0,0 +1,5 @@ +#blacklist-rules { + width: 100%; + height: 450px; + display: block; +} \ No newline at end of file diff --git a/public/src/admin/manage/ip-blacklist.js b/public/src/admin/manage/ip-blacklist.js new file mode 100644 index 0000000000..daba34ec3d --- /dev/null +++ b/public/src/admin/manage/ip-blacklist.js @@ -0,0 +1,41 @@ +'use strict'; +/* globals $, app, socket, templates */ + +define('admin/manage/ip-blacklist', ['settings'], function(Settings) { + + var Blacklist = {}; + + Blacklist.init = function() { + var blacklist = ace.edit("blacklist-rules"); + + blacklist.on('change', function(e) { + $('#blacklist-rules-holder').val(blacklist.getValue()); + }); + + Settings.load('blacklist', $('.blacklist-settings'), function(err, settings) { + blacklist.setValue(settings.rules); + }); + + $('[data-action="apply"]').on('click', function() { + Settings.save('blacklist', $('.blacklist-settings'), function() { + app.alert({ + type: 'success', + alert_id: 'blacklist-saved', + title: 'Blacklist Applied', + }); + }); + }); + + $('[data-action="test"]').on('click', function() { + socket.emit('admin.blacklist.validate', { + rules: blacklist.getValue() + }, function(err, data) { + templates.parse('admin/partials/blacklist-validate', data, function(html) { + bootbox.alert(html); + }); + }); + }); + }; + + return Blacklist; +}); \ No newline at end of file diff --git a/src/controllers/admin.js b/src/controllers/admin.js index 8b9b1cafc7..2bba60cae6 100644 --- a/src/controllers/admin.js +++ b/src/controllers/admin.js @@ -5,6 +5,7 @@ var adminController = { categories: require('./admin/categories'), tags: require('./admin/tags'), flags: require('./admin/flags'), + blacklist: require('./admin/blacklist'), groups: require('./admin/groups'), appearance: require('./admin/appearance'), extend: { diff --git a/src/controllers/admin/blacklist.js b/src/controllers/admin/blacklist.js new file mode 100644 index 0000000000..60c3940e44 --- /dev/null +++ b/src/controllers/admin/blacklist.js @@ -0,0 +1,9 @@ +"use strict"; + +var blacklistController = {}; + +blacklistController.get = function(req, res, next) { + res.render('admin/manage/ip-blacklist', {}); +}; + +module.exports = blacklistController; diff --git a/src/meta.js b/src/meta.js index 7a054b1836..716d90e9cb 100644 --- a/src/meta.js +++ b/src/meta.js @@ -26,6 +26,7 @@ var async = require('async'), require('./meta/tags')(Meta); require('./meta/dependencies')(Meta); Meta.templates = require('./meta/templates'); + Meta.blacklist = require('./meta/blacklist'); /* Assorted */ Meta.userOrGroupExists = function(slug, callback) { diff --git a/src/meta/blacklist.js b/src/meta/blacklist.js new file mode 100644 index 0000000000..2029b406f2 --- /dev/null +++ b/src/meta/blacklist.js @@ -0,0 +1,106 @@ +'use strict'; + +var ip = require('ip'), + winston = require('winston'), + async = require('async'); + +var meta = module.parent.exports; + +var Blacklist = { + _rules: [] + }; + +Blacklist.load = function(callback) { + async.waterfall([ + async.apply(meta.settings.getOne, 'blacklist', 'rules'), + async.apply(Blacklist.validate) + ], function(err, rules) { + if (err) { + return callback(err); + } + + winston.verbose('[meta/blacklist] Loading ' + rules.valid.length + ' blacklist rules'); + if (rules.invalid.length) { + winston.warn('[meta/blacklist] ' + rules.invalid.length + ' invalid blacklist rule(s) were ignored.'); + } + + Blacklist._rules = { + ipv4: rules.ipv4, + ipv6: rules.ipv6, + cidr: rules.cidr + }; + + callback(); + }); +}; + +Blacklist.test = function(clientIp, callback) { + if ( + Blacklist._rules.ipv4.indexOf(clientIp) === -1 // not explicitly specified in ipv4 list + && Blacklist._rules.ipv6.indexOf(clientIp) === -1 // not explicitly specified in ipv6 list + && !Blacklist._rules.cidr.some(function(subnet) { + return ip.cidrSubnet(subnet).contains(clientIp); + }) // not in a blacklisted cidr range + ) { + if (typeof callback === 'function') { + callback(); + } else { + return false; + } + } else { + var err = new Error('[[error:blacklisted-ip]]'); + err.code = 'blacklisted-ip'; + + if (typeof callback === 'function') { + callback(err); + } else { + return true; + } + } +}; + +Blacklist.validate = function(rules, callback) { + var rules = (rules || '').split('\n'), + ipv4 = [], + ipv6 = [], + cidr = [], + invalid = []; + + var isCidrSubnet = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$/; + + // Filter out blank lines and lines starting with the hash character (comments) + rules = rules.map(function(rule) { + rule = rule.trim(); + return rule.length && !rule.startsWith('#') ? rule : null; + }).filter(Boolean); + + // Filter out invalid rules + rules = rules.filter(function(rule) { + if (ip.isV4Format(rule)) { + ipv4.push(rule); + return true; + } else if (ip.isV6Format(rule)) { + ipv6.push(rule); + return true; + } else if (isCidrSubnet.test(rule)) { + cidr.push(rule); + return true; + } else { + invalid.push(rule); + return false; + } + + return true; + }); + + callback(null, { + numRules: rules.length + invalid.length, + ipv4: ipv4, + ipv6: ipv6, + cidr: cidr, + valid: rules, + invalid: invalid + }); +}; + +module.exports = Blacklist; \ No newline at end of file diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index d5e1d93388..52adf21ab4 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -257,6 +257,13 @@ middleware.busyCheck = function(req, res, next) { } }; +middleware.applyBlacklist = function(req, res, next) { + meta.blacklist.test(req.ip, function(err) { + console.log('blacklist returned:', err); + next(err); + }); +}; + module.exports = function(webserver) { app = webserver; middleware.admin = require('./admin')(webserver); diff --git a/src/routes/admin.js b/src/routes/admin.js index f5d4d039a1..56c97ff1f2 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -54,8 +54,8 @@ function addRoutes(router, middleware, controllers) { router.get('/manage/categories/:category_id', middlewares, controllers.admin.categories.get); router.get('/manage/tags', middlewares, controllers.admin.tags.get); - router.get('/manage/flags', middlewares, controllers.admin.flags.get); + router.get('/manage/ip-blacklist', middlewares, controllers.admin.blacklist.get); router.get('/manage/users', middlewares, controllers.admin.users.sortByJoinDate); router.get('/manage/users/search', middlewares, controllers.admin.users.search); diff --git a/src/routes/authentication.js b/src/routes/authentication.js index 37439ba523..8e1824cad6 100644 --- a/src/routes/authentication.js +++ b/src/routes/authentication.js @@ -62,8 +62,8 @@ })); }); - router.post('/register', Auth.middleware.applyCSRF, controllers.authentication.register); - router.post('/login', Auth.middleware.applyCSRF, controllers.authentication.login); + router.post('/register', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.register); + router.post('/login', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.login); router.post('/logout', Auth.middleware.applyCSRF, controllers.authentication.logout); hotswap.replace('auth', router); diff --git a/src/routes/index.js b/src/routes/index.js index 7e84f29af1..e49256f933 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -187,9 +187,14 @@ function handle404(app, middleware) { function handleErrors(app, middleware) { app.use(function(err, req, res, next) { - if (err.code === 'EBADCSRFTOKEN') { - winston.error(req.path + '\n', err.message); - return res.sendStatus(403); + switch (err.code) { + case 'EBADCSRFTOKEN': + winston.error(req.path + '\n', err.message); + return res.sendStatus(403); + break; + case 'blacklisted-ip': + return res.status(403).type('text/plain').send(err.message); + break; } if (parseInt(err.status, 10) === 302 && err.path) { diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js index 6dd22134ba..f3504a920d 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -33,7 +33,8 @@ var async = require('async'), settings: {}, email: {}, analytics: {}, - logs: {} + logs: {}, + blacklist: {} }; SocketAdmin.before = function(socket, method, data, next) { @@ -273,5 +274,8 @@ SocketAdmin.deleteAllEvents = function(socket, data, callback) { events.deleteAll(callback); }; +SocketAdmin.blacklist.validate = function(socket, data, callback) { + meta.blacklist.validate(data.rules, callback); +}; module.exports = SocketAdmin; diff --git a/src/views/admin/manage/ip-blacklist.tpl b/src/views/admin/manage/ip-blacklist.tpl new file mode 100644 index 0000000000..13e43a84d7 --- /dev/null +++ b/src/views/admin/manage/ip-blacklist.tpl @@ -0,0 +1,31 @@ +
+ Configure your IP blacklist here. +
++ Occasionally, a user account ban is not enough of a deterrant. Other times, restricting access to the forum to a specific IP or a range of IPs + is the best way to protect a forum. In these scenarios, you can add troublesome IP addresses or entire CIDR blocks to this blacklist, and + they will be prevented from logging in to or registering a new account. +
+ ++ {valid.length} out of {numRules} rule(s) valid. +
+ + ++ The following {invalid.length} rules are invalid: +
+@value