mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-28 21:33:24 +03:00
Replace memory spam prevention with brute-express (#7579)
no issue - removes count from user checks model - uses brute express brute with brute-knex adaptor to store persisted data on spam prevention - implement brute force protection for password/token exchange, password resets and private blogging
This commit is contained in:
parent
b3f09347e4
commit
68af2145a1
@ -11,7 +11,7 @@ var debug = require('debug')('ghost:api'),
|
||||
// API specific
|
||||
auth = require('../auth'),
|
||||
cors = require('../middleware/api/cors'), // routes only?!
|
||||
spamPrevention = require('../middleware/api/spam-prevention'), // routes only
|
||||
brute = require('../middleware/brute'), // routes only
|
||||
versionMatch = require('../middleware/api/version-match'), // global
|
||||
|
||||
// Handling uploads & imports
|
||||
@ -170,17 +170,21 @@ function apiRoutes() {
|
||||
|
||||
// ## Authentication
|
||||
apiRouter.post('/authentication/passwordreset',
|
||||
spamPrevention.forgotten,
|
||||
// Prevent more than 5 password resets from an ip in an hour for any email address
|
||||
brute.globalReset,
|
||||
// Prevent more than 5 password resets in an hour for an email+IP pair
|
||||
brute.userReset,
|
||||
api.http(api.authentication.generateResetToken)
|
||||
);
|
||||
apiRouter.put('/authentication/passwordreset', api.http(api.authentication.resetPassword));
|
||||
apiRouter.put('/authentication/passwordreset', brute.globalBlock, api.http(api.authentication.resetPassword));
|
||||
apiRouter.post('/authentication/invitation', api.http(api.authentication.acceptInvitation));
|
||||
apiRouter.get('/authentication/invitation', api.http(api.authentication.isInvitation));
|
||||
apiRouter.post('/authentication/setup', api.http(api.authentication.setup));
|
||||
apiRouter.put('/authentication/setup', authenticatePrivate, api.http(api.authentication.updateSetup));
|
||||
apiRouter.get('/authentication/setup', api.http(api.authentication.isSetup));
|
||||
apiRouter.post('/authentication/token',
|
||||
spamPrevention.signin,
|
||||
brute.globalBlock,
|
||||
brute.userLogin,
|
||||
auth.authenticate.authenticateClient,
|
||||
auth.oauth.generateAccessToken
|
||||
);
|
||||
|
@ -13,6 +13,7 @@ var _ = require('lodash'),
|
||||
events = require('../events'),
|
||||
config = require('../config'),
|
||||
i18n = require('../i18n'),
|
||||
spamPrevention = require('../middleware/api/spam-prevention'),
|
||||
authentication,
|
||||
tokenSecurity = {};
|
||||
|
||||
@ -342,6 +343,8 @@ authentication = {
|
||||
}));
|
||||
}
|
||||
|
||||
spamPrevention.userLogin.reset(null, options.data.connection + tokenParts.email + 'login');
|
||||
|
||||
return models.User.changePassword({
|
||||
oldPassword: oldPassword,
|
||||
newPassword: newPassword,
|
||||
|
@ -7,11 +7,9 @@ var _ = require('lodash'),
|
||||
config = require('../../../config'),
|
||||
api = require('../../../api'),
|
||||
errors = require('../../../errors'),
|
||||
logging = require('../../../logging'),
|
||||
utils = require('../../../utils'),
|
||||
i18n = require('../../../i18n'),
|
||||
privateRoute = '/' + config.get('routeKeywords').private + '/',
|
||||
protectedSecurity = [],
|
||||
privateBlogging;
|
||||
|
||||
function verifySessionHash(salt, hash) {
|
||||
@ -136,50 +134,6 @@ privateBlogging = {
|
||||
return next();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
spamPrevention: function spamPrevention(req, res, next) {
|
||||
var currentTime = process.hrtime()[0],
|
||||
remoteAddress = req.connection.remoteAddress,
|
||||
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
|
||||
rateProtectedAttempts = config.rateProtectedAttempts || 10,
|
||||
ipCount = '',
|
||||
message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'),
|
||||
deniedRateLimit = '',
|
||||
password = req.body.password;
|
||||
|
||||
if (password) {
|
||||
protectedSecurity.push({ip: remoteAddress, time: currentTime});
|
||||
} else {
|
||||
res.error = {
|
||||
message: i18n.t('errors.middleware.spamprevention.noPassword')
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// filter entries that are older than rateProtectedPeriod
|
||||
protectedSecurity = _.filter(protectedSecurity, function filter(logTime) {
|
||||
return (logTime.time + rateProtectedPeriod > currentTime);
|
||||
});
|
||||
|
||||
ipCount = _.chain(protectedSecurity).countBy('ip').value();
|
||||
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
|
||||
|
||||
if (deniedRateLimit) {
|
||||
logging.error(new errors.GhostError({
|
||||
message: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateProtectedAttempts, rfp: rateProtectedPeriod}),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
|
||||
message += rateProtectedPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater');
|
||||
|
||||
// @TODO: why?
|
||||
res.error = {
|
||||
message: message
|
||||
};
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -4,6 +4,7 @@ var path = require('path'),
|
||||
bodyParser = require('body-parser'),
|
||||
templates = require('../../../controllers/frontend/templates'),
|
||||
setResponseContext = require('../../../controllers/frontend/context'),
|
||||
brute = require('../../../middleware/brute'),
|
||||
privateRouter = express.Router();
|
||||
|
||||
function controller(req, res) {
|
||||
@ -32,7 +33,7 @@ privateRouter.route('/')
|
||||
.post(
|
||||
bodyParser.urlencoded({extended: true}),
|
||||
middleware.isPrivateSessionAuth,
|
||||
middleware.spamPrevention,
|
||||
brute.privateBlog,
|
||||
middleware.authenticateProtection,
|
||||
controller
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
/*globals describe, beforeEach, afterEach, before, it*/
|
||||
/*globals describe, beforeEach, afterEach, it*/
|
||||
var crypto = require('crypto'),
|
||||
should = require('should'),
|
||||
sinon = require('sinon'),
|
||||
@ -288,77 +288,4 @@ describe('Private Blogging', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spamPrevention', function () {
|
||||
var error = null,
|
||||
res, req, spyNext;
|
||||
|
||||
before(function () {
|
||||
spyNext = sinon.spy(function (param) {
|
||||
error = param;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
res = sinon.spy();
|
||||
req = {
|
||||
connection: {
|
||||
remoteAddress: '10.0.0.0'
|
||||
},
|
||||
body: {
|
||||
password: 'password'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it ('sets an error when there is no password', function (done) {
|
||||
req.body = {};
|
||||
|
||||
privateBlogging.spamPrevention(req, res, spyNext);
|
||||
res.error.message.should.equal('No password entered');
|
||||
spyNext.calledOnce.should.be.true();
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it ('sets and error message after 10 tries', function (done) {
|
||||
var ndx;
|
||||
|
||||
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
|
||||
privateBlogging.spamPrevention(req, res, spyNext);
|
||||
}
|
||||
|
||||
should.not.exist(res.error);
|
||||
privateBlogging.spamPrevention(req, res, spyNext);
|
||||
should.exist(res.error);
|
||||
should.exist(res.error.message);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it ('allows more tries after an hour', function (done) {
|
||||
var ndx,
|
||||
stub = sinon.stub(process, 'hrtime', function () {
|
||||
return [10, 10];
|
||||
});
|
||||
|
||||
for (ndx = 0; ndx < 11; ndx = ndx + 1) {
|
||||
privateBlogging.spamPrevention(req, res, spyNext);
|
||||
}
|
||||
|
||||
should.exist(res.error);
|
||||
process.hrtime.restore();
|
||||
stub = sinon.stub(process, 'hrtime', function () {
|
||||
return [3610000, 10];
|
||||
});
|
||||
|
||||
res = sinon.spy();
|
||||
|
||||
privateBlogging.spamPrevention(req, res, spyNext);
|
||||
should.not.exist(res.error);
|
||||
|
||||
process.hrtime.restore();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -39,8 +39,8 @@ function exchangeRefreshToken(client, refreshToken, scope, done) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function exchangePassword(client, username, password, scope, done) {
|
||||
// We are required to pass in authInfo in order to reset spam counter for user login
|
||||
function exchangePassword(client, username, password, scope, body, authInfo, done) {
|
||||
// Validate the client
|
||||
models.Client.findOne({slug: client.slug})
|
||||
.then(function then(client) {
|
||||
@ -54,7 +54,8 @@ function exchangePassword(client, username, password, scope, done) {
|
||||
return authenticationAPI.createTokens({}, {context: {client_id: client.id, user: user.id}});
|
||||
})
|
||||
.then(function then(response) {
|
||||
spamPrevention.resetCounter(username);
|
||||
// Reset spam count for username and IP pair
|
||||
spamPrevention.userLogin.reset(null, authInfo.ip + username + 'login');
|
||||
return done(null, response.access_token, response.refresh_token, {expires_in: response.expires_in});
|
||||
});
|
||||
})
|
||||
|
@ -35,5 +35,36 @@
|
||||
"enabled": false
|
||||
},
|
||||
"transports": ["stdout"]
|
||||
},
|
||||
"spam": {
|
||||
"user_login": {
|
||||
"minWait": 600000,
|
||||
"maxWait": 604800000,
|
||||
"freeRetries": 4
|
||||
},
|
||||
"user_reset": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 4
|
||||
},
|
||||
"global_reset": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":4
|
||||
},
|
||||
"global_block": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":99
|
||||
},
|
||||
"private_block": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":99
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
core/server/config/env/config.testing-mysql.json
vendored
31
core/server/config/env/config.testing-mysql.json
vendored
@ -17,5 +17,36 @@
|
||||
},
|
||||
"logging": {
|
||||
"level": "fatal"
|
||||
},
|
||||
"spam": {
|
||||
"user_login": {
|
||||
"minWait": 600000,
|
||||
"maxWait": 604800000,
|
||||
"freeRetries": 3
|
||||
},
|
||||
"user_reset": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 4
|
||||
},
|
||||
"global_reset": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":4
|
||||
},
|
||||
"global_block": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":4
|
||||
},
|
||||
"private_block": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":99
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
core/server/config/env/config.testing.json
vendored
31
core/server/config/env/config.testing.json
vendored
@ -14,5 +14,36 @@
|
||||
},
|
||||
"logging": {
|
||||
"level": "fatal"
|
||||
},
|
||||
"spam": {
|
||||
"user_login": {
|
||||
"minWait": 600000,
|
||||
"maxWait": 604800000,
|
||||
"freeRetries": 3
|
||||
},
|
||||
"user_reset": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 4
|
||||
},
|
||||
"global_reset": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":4
|
||||
},
|
||||
"global_block": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":4
|
||||
},
|
||||
"private_block": {
|
||||
"minWait": 3600000,
|
||||
"maxWait": 3600000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries":99
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -232,5 +232,12 @@ module.exports = {
|
||||
id: {type: 'increments', nullable: false, primary: true},
|
||||
role_id: {type: 'integer', nullable: false},
|
||||
invite_id: {type: 'integer', nullable: false}
|
||||
},
|
||||
brute: {
|
||||
key: {type: 'string'},
|
||||
firstRequest: {type: 'timestamp'},
|
||||
lastRequest: {type: 'timestamp'},
|
||||
lifetime: {type: 'bigInteger'},
|
||||
count: {type: 'integer'}
|
||||
}
|
||||
};
|
||||
|
@ -1,129 +1,128 @@
|
||||
// # SpamPrevention Middleware
|
||||
// Usage: spamPrevention
|
||||
// After:
|
||||
// Before:
|
||||
// App: Admin|Blog|API
|
||||
//
|
||||
// Helpers to handle spam detection on signin, forgot password, and protected pages.
|
||||
var ExpressBrute = require('express-brute'),
|
||||
BruteKnex = require('brute-knex'),
|
||||
knexInstance = require('../../data/db/connection'),
|
||||
store = new BruteKnex({tablename: 'brute', createTable:false, knex: knexInstance}),
|
||||
moment = require('moment'),
|
||||
errors = require('../../errors'),
|
||||
config = require('../../config'),
|
||||
spam = config.get('spam') || {},
|
||||
_ = require('lodash'),
|
||||
spamPrivateBlog = spam.private_blog || {},
|
||||
spamGlobalBlock = spam.global_block || {},
|
||||
spamGlobalReset = spam.global_reset || {},
|
||||
spamUserReset = spam.user_reset || {},
|
||||
spamUserLogin = spam.user_login || {},
|
||||
|
||||
var _ = require('lodash'),
|
||||
errors = require('../../errors'),
|
||||
config = require('../../config'),
|
||||
i18n = require('../../i18n'),
|
||||
loginSecurity = [],
|
||||
forgottenSecurity = [],
|
||||
spamPrevention;
|
||||
i18n = require('../../i18n'),
|
||||
handleStoreError,
|
||||
globalBlock,
|
||||
globalReset,
|
||||
privateBlog,
|
||||
userLogin,
|
||||
userReset,
|
||||
logging = require('../../logging'),
|
||||
spamConfigKeys = ['freeRetries', 'minWait', 'maxWait', 'lifetime'];
|
||||
|
||||
spamPrevention = {
|
||||
/*jslint unparam:true*/
|
||||
// limit signin requests to ten failed requests per IP per hour
|
||||
signin: function signin(req, res, next) {
|
||||
var currentTime = process.hrtime()[0],
|
||||
remoteAddress = req.connection.remoteAddress,
|
||||
deniedRateLimit = '',
|
||||
ipCount = '',
|
||||
rateSigninPeriod = config.rateSigninPeriod || 3600,
|
||||
rateSigninAttempts = config.rateSigninAttempts || 10;
|
||||
|
||||
if (req.body.username && req.body.grant_type === 'password') {
|
||||
loginSecurity.push({ip: remoteAddress, time: currentTime, email: req.body.username});
|
||||
} else if (req.body.grant_type === 'refresh_token' || req.body.grant_type === 'authorization_code') {
|
||||
return next();
|
||||
} else {
|
||||
return next(new errors.BadRequestError({message: i18n.t('errors.middleware.spamprevention.noUsername')}));
|
||||
}
|
||||
|
||||
// filter entries that are older than rateSigninPeriod
|
||||
loginSecurity = _.filter(loginSecurity, function filter(logTime) {
|
||||
return (logTime.time + rateSigninPeriod > currentTime);
|
||||
});
|
||||
|
||||
// check number of tries per IP address
|
||||
ipCount = _.chain(loginSecurity).countBy('ip').value();
|
||||
deniedRateLimit = (ipCount[remoteAddress] > rateSigninAttempts);
|
||||
|
||||
if (deniedRateLimit) {
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: i18n.t('errors.middleware.spamprevention.tooManyAttempts') + rateSigninPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'),
|
||||
context: i18n.t('errors.middleware.spamprevention.tooManySigninAttempts.error', {rateSigninAttempts: rateSigninAttempts, rateSigninPeriod: rateSigninPeriod}),
|
||||
help: i18n.t('errors.middleware.spamprevention.tooManySigninAttempts.context')
|
||||
}));
|
||||
}
|
||||
next();
|
||||
},
|
||||
|
||||
// limit forgotten password requests to five requests per IP per hour for different email addresses
|
||||
// limit forgotten password requests to five requests per email address
|
||||
// @TODO: add validation check to validation middleware
|
||||
forgotten: function forgotten(req, res, next) {
|
||||
if (!req.body.passwordreset) {
|
||||
return next(new errors.BadRequestError({
|
||||
message: i18n.t('errors.api.utils.noRootKeyProvided', {docName: 'passwordreset'})
|
||||
}));
|
||||
}
|
||||
|
||||
var currentTime = process.hrtime()[0],
|
||||
remoteAddress = req.connection.remoteAddress,
|
||||
rateForgottenPeriod = config.rateForgottenPeriod || 3600,
|
||||
rateForgottenAttempts = config.rateForgottenAttempts || 5,
|
||||
email = req.body.passwordreset[0].email,
|
||||
ipCount = '',
|
||||
deniedRateLimit = '',
|
||||
deniedEmailRateLimit = '',
|
||||
index = _.findIndex(forgottenSecurity, function findIndex(logTime) {
|
||||
return (logTime.ip === remoteAddress && logTime.email === email);
|
||||
});
|
||||
|
||||
if (email) {
|
||||
if (index !== -1) {
|
||||
forgottenSecurity[index].count = forgottenSecurity[index].count + 1;
|
||||
} else {
|
||||
forgottenSecurity.push({ip: remoteAddress, time: currentTime, email: email, count: 0});
|
||||
}
|
||||
} else {
|
||||
return next(new errors.BadRequestError({message: i18n.t('errors.middleware.spamprevention.noEmail')}));
|
||||
}
|
||||
|
||||
// filter entries that are older than rateForgottenPeriod
|
||||
forgottenSecurity = _.filter(forgottenSecurity, function filter(logTime) {
|
||||
return (logTime.time + rateForgottenPeriod > currentTime);
|
||||
});
|
||||
|
||||
// check number of tries with different email addresses per IP
|
||||
ipCount = _.chain(forgottenSecurity).countBy('ip').value();
|
||||
deniedRateLimit = (ipCount[remoteAddress] > rateForgottenAttempts);
|
||||
|
||||
if (index !== -1) {
|
||||
deniedEmailRateLimit = (forgottenSecurity[index].count > rateForgottenAttempts);
|
||||
}
|
||||
|
||||
if (deniedEmailRateLimit) {
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: i18n.t('errors.middleware.spamprevention.tooManyAttempts') + rateForgottenPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordEmail.error', {
|
||||
rfa: rateForgottenAttempts,
|
||||
rfp: rateForgottenPeriod
|
||||
}),
|
||||
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordEmail.context')
|
||||
}));
|
||||
}
|
||||
|
||||
if (deniedRateLimit) {
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: i18n.t('errors.middleware.spamprevention.tooManyAttempts') + rateForgottenPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateForgottenAttempts, rfp: rateForgottenPeriod}),
|
||||
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
|
||||
resetCounter: function resetCounter(email) {
|
||||
loginSecurity = _.filter(loginSecurity, function filter(logTime) {
|
||||
return (logTime.email !== email);
|
||||
});
|
||||
}
|
||||
handleStoreError = function handleStoreError(err) {
|
||||
return new errors.NoPermissionError({message: 'DB error', err: err});
|
||||
};
|
||||
|
||||
module.exports = spamPrevention;
|
||||
// This is a global endpoint protection mechanism that will lock an endpoint if there are so many
|
||||
// requests from a single IP
|
||||
// We allow for a generous number of requests here to prevent communites on the same IP bing barred on account of a single suer
|
||||
// Defaults to 50 attempts per hour and locks the endpoint for an hour
|
||||
globalBlock = new ExpressBrute(store,
|
||||
_.extend({
|
||||
attachResetToRequest: false,
|
||||
failCallback: function (req, res, next, nextValidRequestDate) {
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
|
||||
{rfa: spamGlobalBlock.freeRetries + 1 || 5, rfp: spamGlobalBlock.lifetime || 60 * 60}),
|
||||
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
},
|
||||
handleStoreError: handleStoreError
|
||||
}, _.pick(spamGlobalBlock, spamConfigKeys))
|
||||
);
|
||||
|
||||
globalReset = new ExpressBrute(store,
|
||||
_.extend({
|
||||
attachResetToRequest: false,
|
||||
failCallback: function (req, res, next, nextValidRequestDate) {
|
||||
// TODO use i18n again
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
|
||||
{rfa: spamGlobalReset.freeRetries + 1 || 5, rfp: spamGlobalReset.lifetime || 60 * 60}),
|
||||
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
},
|
||||
handleStoreError: handleStoreError
|
||||
}, _.pick(spamGlobalBlock, spamConfigKeys))
|
||||
);
|
||||
|
||||
// Stops login attempts for a user+IP pair with an increasing time period starting from 10 minutes
|
||||
// and rising to a week in a fibonnaci sequence
|
||||
// The user+IP count is reset when on successful login
|
||||
// Default value of 5 attempts per user+IP pair
|
||||
userLogin = new ExpressBrute(store,
|
||||
_.extend({
|
||||
attachResetToRequest: true,
|
||||
failCallback: function (req, res, next, nextValidRequestDate) {
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
|
||||
{rfa: spamUserLogin.freeRetries + 1 || 5, rfp: spamUserLogin.lifetime || 60 * 60}),
|
||||
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
},
|
||||
handleStoreError: handleStoreError
|
||||
}, _.pick(spamUserLogin, spamConfigKeys))
|
||||
);
|
||||
|
||||
// Stop password reset requests when there are (freeRetries + 1) requests per lifetime per email
|
||||
// Defaults here are 5 attempts per hour for a user+IP pair
|
||||
// The endpoint is then locked for an hour
|
||||
userReset = new ExpressBrute(store,
|
||||
_.extend({
|
||||
attachResetToRequest: true,
|
||||
failCallback: function (req, res, next, nextValidRequestDate) {
|
||||
return next(new errors.TooManyRequestsError({
|
||||
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
|
||||
{rfa: spamUserReset.freeRetries + 1 || 5, rfp: spamUserReset.lifetime || 60 * 60}),
|
||||
help: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
},
|
||||
handleStoreError: handleStoreError
|
||||
}, _.pick(spamUserReset, spamConfigKeys))
|
||||
);
|
||||
|
||||
// This protects a private blog from spam attacks. The defaults here allow 10 attempts per IP per hour
|
||||
// The endpoint is then locked for an hour
|
||||
privateBlog = new ExpressBrute(store,
|
||||
_.extend({
|
||||
attachResetToRequest: false,
|
||||
failCallback: function (req, res, next, nextValidRequestDate) {
|
||||
logging.error(new errors.GhostError({
|
||||
message: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error',
|
||||
{rfa: spamPrivateBlog.freeRetries + 1 || 5, rfp: spamPrivateBlog.lifetime || 60 * 60}),
|
||||
context: i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
|
||||
}));
|
||||
|
||||
return next(new errors.GhostError({
|
||||
message: 'Too many attempts try again in ' + moment(nextValidRequestDate).fromNow(true)
|
||||
}));
|
||||
},
|
||||
handleStoreError: handleStoreError
|
||||
}, _.pick(spamPrivateBlog, spamConfigKeys))
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
globalBlock: globalBlock,
|
||||
globalReset: globalReset,
|
||||
userLogin: userLogin,
|
||||
userReset: userReset,
|
||||
privateBlog: privateBlog
|
||||
};
|
||||
|
50
core/server/middleware/brute.js
Normal file
50
core/server/middleware/brute.js
Normal file
@ -0,0 +1,50 @@
|
||||
var spamPrevention = require('./api/spam-prevention');
|
||||
|
||||
module.exports = {
|
||||
globalBlock: spamPrevention.globalBlock.getMiddleware({
|
||||
// We want to ignore req.ip and instead use req.connection.remoteAddress
|
||||
ignoreIP: true,
|
||||
key: function (req, res, next) {
|
||||
req.authInfo = req.authInfo || {};
|
||||
req.authInfo.ip = req.connection.remoteAddress;
|
||||
req.body.connection = req.connection.remoteAddress;
|
||||
next(req.authInfo.ip);
|
||||
}
|
||||
}),
|
||||
globalReset: spamPrevention.globalReset.getMiddleware({
|
||||
ignoreIP: true,
|
||||
key: function (req, res, next) {
|
||||
req.authInfo = req.authInfo || {};
|
||||
req.authInfo.ip = req.connection.remoteAddress;
|
||||
// prevent too many attempts for the same email address but keep separate to login brute force prevention
|
||||
next(req.authInfo.ip);
|
||||
}
|
||||
}),
|
||||
userLogin: spamPrevention.userLogin.getMiddleware({
|
||||
ignoreIP: true,
|
||||
key: function (req, res, next) {
|
||||
req.authInfo = req.authInfo || {};
|
||||
req.authInfo.ip = req.connection.remoteAddress;
|
||||
// prevent too many attempts for the same username
|
||||
next(req.authInfo.ip + req.body.username + 'login');
|
||||
}
|
||||
}),
|
||||
userReset: spamPrevention.userReset.getMiddleware({
|
||||
ignoreIP: true,
|
||||
key: function (req, res, next) {
|
||||
req.authInfo = req.authInfo || {};
|
||||
req.authInfo.ip = req.connection.remoteAddress;
|
||||
// prevent too many attempts for the same email address but keep separate to login brute force prevention
|
||||
next(req.authInfo.ip + req.body.username + 'reset');
|
||||
}
|
||||
}),
|
||||
privateBlog: spamPrevention.privateBlog.getMiddleware({
|
||||
ignoreIP: true,
|
||||
key: function (req, res, next) {
|
||||
req.authInfo = req.authInfo || {};
|
||||
req.authInfo.ip = req.connection.remoteAddress;
|
||||
// prevent too many attempts for the same email address but keep separate to login brute force prevention
|
||||
next(req.authInfo.ip + 'private');
|
||||
}
|
||||
})
|
||||
};
|
@ -582,8 +582,7 @@ User = ghostBookshelf.Model.extend({
|
||||
// Finds the user by email, and checks the password
|
||||
// @TODO: shorten this function and rename...
|
||||
check: function check(object) {
|
||||
var self = this,
|
||||
s;
|
||||
var self = this;
|
||||
|
||||
return this.getByEmail(object.email).then(function then(user) {
|
||||
if (!user) {
|
||||
@ -607,32 +606,11 @@ User = ghostBookshelf.Model.extend({
|
||||
});
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
if (err.code !== 'PASSWORD_INCORRECT') {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
return Promise.resolve(self.setWarning(user, {validate: false}))
|
||||
.then(function then(remaining) {
|
||||
if (remaining === 0) {
|
||||
// If remaining attempts = 0, the account has been locked, so show a locked account message
|
||||
return Promise.reject(new errors.NoPermissionError({
|
||||
message: i18n.t('errors.models.user.accountLocked')
|
||||
}));
|
||||
}
|
||||
|
||||
s = (remaining > 1) ? 's' : '';
|
||||
return Promise.reject(new errors.UnauthorizedError({
|
||||
message: i18n.t('errors.models.user.incorrectPasswordAttempts', {remaining: remaining, s: s})
|
||||
}));
|
||||
}, function handleError(err) {
|
||||
// ^ Use comma structure, not .catch, because we don't want to catch incorrect passwords
|
||||
|
||||
return Promise.reject(new errors.UnauthorizedError({
|
||||
err: err,
|
||||
context: i18n.t('errors.models.user.incorrectPassword'),
|
||||
help: i18n.t('errors.models.user.userUpdateError.help')
|
||||
}));
|
||||
});
|
||||
return Promise.reject(new errors.UnauthorizedError({
|
||||
err: err,
|
||||
context: i18n.t('errors.models.user.incorrectPassword'),
|
||||
help: i18n.t('errors.models.user.userUpdateError.help')
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,12 @@ describe('Authentication API', function () {
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
afterEach(function (done) {
|
||||
testUtils.clearBruteData().then(function () {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
after(function (done) {
|
||||
testUtils.clearData().then(function () {
|
||||
done();
|
||||
|
153
core/test/functional/routes/api/spam_prevention_spec.js
Normal file
153
core/test/functional/routes/api/spam_prevention_spec.js
Normal file
@ -0,0 +1,153 @@
|
||||
var supertest = require('supertest'),
|
||||
should = require('should'),
|
||||
testUtils = require('../../../utils'),
|
||||
user1 = testUtils.DataGenerator.forModel.users[0],
|
||||
user2 = testUtils.DataGenerator.forModel.users[1],
|
||||
config = require('../../../../../core/server/config'),
|
||||
ghostSetup = testUtils.setup('settings', 'users:roles', 'perms:setting', 'perms:notification', 'perms:init'),
|
||||
ghost = testUtils.startGhost,
|
||||
failedLoginAttempt,
|
||||
count,
|
||||
tooManyFailedLoginAttempts,
|
||||
request;
|
||||
|
||||
describe('Spam Prevention API', function () {
|
||||
before(function (done) {
|
||||
ghostSetup()
|
||||
.then(ghost)
|
||||
.then(function (ghostServer) {
|
||||
request = supertest.agent(ghostServer.rootApp);
|
||||
}).then(function () {
|
||||
done();
|
||||
}).then(testUtils.clearBruteData)
|
||||
.catch(done);
|
||||
});
|
||||
|
||||
after(function (done) {
|
||||
testUtils.clearData().then(function () {
|
||||
done();
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
afterEach(function (done) {
|
||||
testUtils.clearBruteData().then(function () {
|
||||
done();
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
it('Too many failed login attempts for a user results in 429 TooManyRequestsError', function (done) {
|
||||
count = 0;
|
||||
|
||||
tooManyFailedLoginAttempts = function tooManyFailedLoginAttempts(email) {
|
||||
request.post(testUtils.API.getApiQuery('authentication/token'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
grant_type: 'password',
|
||||
username: email,
|
||||
password: 'wrong-password',
|
||||
client_id: 'ghost-admin',
|
||||
client_secret: 'not_available'
|
||||
}).expect('Content-Type', /json/)
|
||||
.expect(429)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
var error = res.body.errors[0];
|
||||
should.exist(error.errorType);
|
||||
error.errorType.should.eql('TooManyRequestsError');
|
||||
error.message.should.eql('Too many attempts try again in 10 minutes');
|
||||
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
failedLoginAttempt = function failedLoginAttempt(email) {
|
||||
count += 1;
|
||||
|
||||
request.post(testUtils.API.getApiQuery('authentication/token'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
grant_type: 'password',
|
||||
username: email,
|
||||
password: 'wrong-password',
|
||||
client_id: 'ghost-admin',
|
||||
client_secret: 'not_available'
|
||||
}).expect('Content-Type', /json/)
|
||||
.expect(401)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
if (count < config.get('spam:user_login:freeRetries') + 1) {
|
||||
return failedLoginAttempt(email);
|
||||
}
|
||||
|
||||
tooManyFailedLoginAttempts(email);
|
||||
});
|
||||
};
|
||||
|
||||
failedLoginAttempt(user1.email);
|
||||
});
|
||||
|
||||
it('Too many failed login attempts for multiple users results in 429 TooManyRequestsError', function (done) {
|
||||
count = 0;
|
||||
// We make some unsuccessful login attempts for user1 but not enough to block them. We then make some
|
||||
// failed login attempts for user2 to trigger a global block rather than user specific block
|
||||
|
||||
tooManyFailedLoginAttempts = function tooManyFailedLoginAttempts(email) {
|
||||
request.post(testUtils.API.getApiQuery('authentication/token'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
grant_type: 'password',
|
||||
username: email,
|
||||
password: 'wrong-password',
|
||||
client_id: 'ghost-admin',
|
||||
client_secret: 'not_available'
|
||||
}).expect('Content-Type', /json/)
|
||||
.expect(429)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
var error = res.body.errors[0];
|
||||
should.exist(error.errorType);
|
||||
error.errorType.should.eql('TooManyRequestsError');
|
||||
error.message.should.eql('Too many attempts try again in an hour');
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
failedLoginAttempt = function failedLoginAttempt(email) {
|
||||
count += 1;
|
||||
|
||||
request.post(testUtils.API.getApiQuery('authentication/token'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
grant_type: 'password',
|
||||
username: email,
|
||||
password: 'wrong-password',
|
||||
client_id: 'ghost-admin',
|
||||
client_secret: 'not_available'
|
||||
}).expect('Content-Type', /json/)
|
||||
.expect(401)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
if (count < config.get('spam:user_login:freeRetries') + 1) {
|
||||
return failedLoginAttempt(user1.email);
|
||||
}
|
||||
|
||||
if (count < config.get('spam:global_block:freeRetries') + 1) {
|
||||
return failedLoginAttempt(user2.email);
|
||||
}
|
||||
tooManyFailedLoginAttempts(user2.email);
|
||||
});
|
||||
};
|
||||
|
||||
failedLoginAttempt(user1.email);
|
||||
});
|
||||
});
|
@ -623,34 +623,11 @@ describe('User Model', function run() {
|
||||
throw new Error('User should not have been logged in.');
|
||||
}
|
||||
|
||||
function checkAttemptsError(number) {
|
||||
return function (error) {
|
||||
should.exist(error);
|
||||
|
||||
error.errorType.should.equal('UnauthorizedError');
|
||||
error.message.should.match(new RegExp(number + ' attempt'));
|
||||
|
||||
return UserModel.check(object);
|
||||
};
|
||||
}
|
||||
|
||||
function checkLockedError(error) {
|
||||
should.exist(error);
|
||||
|
||||
error.errorType.should.equal('NoPermissionError');
|
||||
error.message.should.match(/^Your account is locked/);
|
||||
}
|
||||
|
||||
return UserModel.check(object).then(userWasLoggedIn)
|
||||
.catch(checkAttemptsError(4))
|
||||
.then(userWasLoggedIn)
|
||||
.catch(checkAttemptsError(3))
|
||||
.then(userWasLoggedIn)
|
||||
.catch(checkAttemptsError(2))
|
||||
.then(userWasLoggedIn)
|
||||
.catch(checkAttemptsError(1))
|
||||
.then(userWasLoggedIn)
|
||||
.catch(checkLockedError);
|
||||
.catch(function checkError(error) {
|
||||
should.exist(error);
|
||||
error.errorType.should.equal('UnauthorizedError');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -41,6 +41,8 @@ describe('OAuth', function () {
|
||||
req.client = {
|
||||
slug: 'test'
|
||||
};
|
||||
req.authInfo = {};
|
||||
req.authInfo.ip = '127.0.0.1';
|
||||
|
||||
req.body.grant_type = 'password';
|
||||
req.body.username = 'username';
|
||||
|
@ -1,155 +0,0 @@
|
||||
var should = require('should'),
|
||||
sinon = require('sinon'),
|
||||
rewire = require('rewire'),
|
||||
spamPrevention = require('../../../../server/middleware/api/spam-prevention');
|
||||
|
||||
describe('Middleware: spamPrevention', function () {
|
||||
var sandbox,
|
||||
req,
|
||||
next,
|
||||
error,
|
||||
spyNext;
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.sandbox.create();
|
||||
error = null;
|
||||
|
||||
next = sinon.spy();
|
||||
|
||||
spyNext = sinon.spy(function (param) {
|
||||
error = param;
|
||||
});
|
||||
spamPrevention = rewire('../../../../server/middleware/api/spam-prevention');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('signin', function () {
|
||||
beforeEach(function () {
|
||||
req = {
|
||||
connection: {
|
||||
remoteAddress: '10.0.0.0'
|
||||
},
|
||||
body: {
|
||||
username: 'tester',
|
||||
grant_type: 'password'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it('calls next if refreshing the token', function (done) {
|
||||
req.body.grant_type = 'refresh_token';
|
||||
spamPrevention.signin(req, null, next);
|
||||
|
||||
next.calledOnce.should.be.true();
|
||||
done();
|
||||
});
|
||||
|
||||
it ('creates a BadRequestError when there\'s no username', function (done) {
|
||||
req.body = {};
|
||||
|
||||
spamPrevention.signin(req, null, spyNext);
|
||||
|
||||
should.exist(error);
|
||||
error.errorType.should.eql('BadRequestError');
|
||||
done();
|
||||
});
|
||||
|
||||
it ('rate limits after 10 attempts', function (done) {
|
||||
for (var ndx = 0; ndx < 10; ndx = ndx + 1) {
|
||||
spamPrevention.signin(req, null, spyNext);
|
||||
}
|
||||
|
||||
spamPrevention.signin(req, null, spyNext);
|
||||
should.exist(error);
|
||||
error.errorType.should.eql('TooManyRequestsError');
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it ('allows more attempts after an hour', function (done) {
|
||||
var ndx,
|
||||
stub = sinon.stub(process, 'hrtime', function () {
|
||||
return [10, 10];
|
||||
});
|
||||
|
||||
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
|
||||
spamPrevention.signin(req, null, spyNext);
|
||||
}
|
||||
|
||||
spamPrevention.signin(req, null, spyNext);
|
||||
error.errorType.should.eql('TooManyRequestsError');
|
||||
error = null;
|
||||
|
||||
// fast forward 1 hour
|
||||
process.hrtime.restore();
|
||||
stub = sinon.stub(process, 'hrtime', function () {
|
||||
return [3610, 10];
|
||||
});
|
||||
|
||||
spamPrevention.signin(req, null, spyNext);
|
||||
should(error).equal(undefined);
|
||||
spyNext.called.should.be.true();
|
||||
|
||||
process.hrtime.restore();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgotten', function () {
|
||||
beforeEach(function () {
|
||||
req = {
|
||||
connection: {
|
||||
remoteAddress: '10.0.0.0'
|
||||
},
|
||||
body: {
|
||||
passwordreset: [
|
||||
{email:'test@ghost.org'}
|
||||
]
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it ('send a bad request if no email is specified', function (done) {
|
||||
req.body = {
|
||||
passwordreset: [{}]
|
||||
};
|
||||
|
||||
spamPrevention.forgotten(req, null, spyNext);
|
||||
error.errorType.should.eql('BadRequestError');
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it ('creates an unauthorized error after 5 attempts with same email', function (done) {
|
||||
for (var ndx = 0; ndx < 6; ndx = ndx + 1) {
|
||||
spamPrevention.forgotten(req, null, spyNext);
|
||||
}
|
||||
|
||||
spamPrevention.forgotten(req, null, spyNext);
|
||||
error.errorType.should.eql('TooManyRequestsError');
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it ('creates an unauthorized error after 5 attempts from the same ip', function (done) {
|
||||
var ndx, email;
|
||||
|
||||
for (ndx = 0; ndx < 6; ndx = ndx + 1) {
|
||||
email = 'test' + String(ndx) + '@ghost.org';
|
||||
req.body.passwordreset = [
|
||||
{email: email}
|
||||
];
|
||||
|
||||
spamPrevention.forgotten(req, null, spyNext);
|
||||
}
|
||||
|
||||
spamPrevention.forgotten(req, null, spyNext);
|
||||
error.errorType.should.eql('TooManyRequestsError');
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
@ -37,7 +37,8 @@ var Promise = require('bluebird'),
|
||||
|
||||
initFixtures,
|
||||
initData,
|
||||
clearData;
|
||||
clearData,
|
||||
clearBruteData;
|
||||
|
||||
// Require additional assertions which help us keep our tests small and clear
|
||||
require('./assertions');
|
||||
@ -405,6 +406,10 @@ initData = function initData() {
|
||||
return knexMigrator.init();
|
||||
};
|
||||
|
||||
clearBruteData = function clearBruteData() {
|
||||
return db.knex('brute').truncate();
|
||||
};
|
||||
|
||||
// we must always try to delete all tables
|
||||
clearData = function clearData() {
|
||||
debug('Database reset');
|
||||
@ -681,6 +686,7 @@ module.exports = {
|
||||
initFixtures: initFixtures,
|
||||
initData: initData,
|
||||
clearData: clearData,
|
||||
clearBruteData: clearBruteData,
|
||||
|
||||
mocks: mocks,
|
||||
|
||||
|
@ -31,6 +31,7 @@
|
||||
"bluebird": "3.4.6",
|
||||
"body-parser": "1.15.2",
|
||||
"bookshelf": "0.10.2",
|
||||
"brute-knex": "git://github.com/cobbspur/brute-knex.git#0985c50",
|
||||
"bunyan": "1.8.1",
|
||||
"chalk": "1.1.3",
|
||||
"cheerio": "0.22.0",
|
||||
@ -43,6 +44,7 @@
|
||||
"debug": "2.2.0",
|
||||
"downsize": "0.0.8",
|
||||
"express": "4.14.0",
|
||||
"express-brute": "1.0.1",
|
||||
"express-hbs": "1.0.3",
|
||||
"extract-zip-fork": "1.5.1",
|
||||
"fs-extra": "0.30.0",
|
||||
|
Loading…
Reference in New Issue
Block a user