0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Move the spam prevention into its own file.

issue #5286
- Moved the spam prevention functions into their own file
- Added unit tests for the functions
This commit is contained in:
Alex Kleissner 2015-05-26 12:04:27 -07:00
parent e7a078a541
commit 766bf99de9
6 changed files with 395 additions and 155 deletions

View file

@ -232,7 +232,7 @@ setupMiddleware = function (blogAppInstance, adminApp) {
blogApp = blogAppInstance;
middleware.cacheBlogApp(blogApp);
middleware.cacheOauthServer(oauthServer);
oauth.init(oauthServer, middleware.resetSpamCounter);
oauth.init(oauthServer, middleware.spamPrevention.resetCounter);
// Make sure 'req.secure' is valid for proxied requests
// (X-Forwarded-Proto header will be checked, if present)

View file

@ -18,13 +18,11 @@ var _ = require('lodash'),
busboy = require('./ghost-busboy'),
cacheControl = require('./cache-control'),
spamPrevention = require('./spam-prevention'),
middleware,
blogApp,
oauthServer,
loginSecurity = [],
forgottenSecurity = [],
protectedSecurity = [];
oauthServer;
function isBlackListedFileType(file) {
var blackListedFileTypes = ['.hbs', '.md', '.json'],
@ -174,112 +172,6 @@ middleware = {
express['static'](path.join(config.paths.themePath, activeTheme.value), {maxAge: utils.ONE_YEAR_MS})(req, res, next);
});
},
// ### Spam prevention Middleware
// limit signin requests to ten failed requests per IP per hour
spamSigninPrevention: function (req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
deniedRateLimit = '',
ipCount = '',
message = 'Too many attempts.',
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') {
return next();
} else {
return next(new errors.BadRequestError('No username.'));
}
// filter entries that are older than rateSigninPeriod
loginSecurity = _.filter(loginSecurity, function (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) {
errors.logError(
'Only ' + rateSigninAttempts + ' tries per IP address every ' + rateSigninPeriod + ' seconds.',
'Too many login attempts.'
);
message += rateSigninPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
return next(new errors.UnauthorizedError(message));
}
next();
},
// ### Spam prevention Middleware
// 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
spamForgottenPrevention: function (req, res, next) {
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 = '',
message = 'Too many attempts.',
index = _.findIndex(forgottenSecurity, function (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('No email.'));
}
// filter entries that are older than rateForgottenPeriod
forgottenSecurity = _.filter(forgottenSecurity, function (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) {
errors.logError(
'Only ' + rateForgottenAttempts + ' forgotten password attempts per email every ' +
rateForgottenPeriod + ' seconds.',
'Forgotten password reset attempt failed'
);
}
if (deniedRateLimit) {
errors.logError(
'Only ' + rateForgottenAttempts + ' tries per IP address every ' + rateForgottenPeriod + ' seconds.',
'Forgotten password reset attempt failed'
);
}
if (deniedEmailRateLimit || deniedRateLimit) {
message += rateForgottenPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
return next(new errors.UnauthorizedError(message));
}
next();
},
resetSpamCounter: function (email) {
loginSecurity = _.filter(loginSecurity, function (logTime) {
return (logTime.email !== email);
});
},
// work around to handle missing client_secret
// oauth2orize needs it, but untrusted clients don't have it
@ -402,46 +294,6 @@ middleware = {
});
},
spamProtectedPrevention: function (req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
rateProtectedAttempts = config.rateProtectedAttempts || 10,
ipCount = '',
message = 'Too many attempts.',
deniedRateLimit = '',
password = req.body.password;
if (password) {
protectedSecurity.push({ip: remoteAddress, time: currentTime});
} else {
res.error = {
message: 'No password entered'
};
return next();
}
// filter entries that are older than rateProtectedPeriod
protectedSecurity = _.filter(protectedSecurity, function (logTime) {
return (logTime.time + rateProtectedPeriod > currentTime);
});
ipCount = _.chain(protectedSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
if (deniedRateLimit) {
errors.logError(
'Only ' + rateProtectedAttempts + ' tries per IP address every ' + rateProtectedPeriod + ' seconds.',
'Too many login attempts.'
);
message += rateProtectedPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
res.error = {
message: message
};
}
return next();
},
authenticateProtection: function (req, res, next) {
// if errors have been generated from the previous call
if (res.error) {
@ -472,7 +324,8 @@ middleware = {
},
busboy: busboy,
cacheControl: cacheControl
cacheControl: cacheControl,
spamPrevention: spamPrevention
};
module.exports = middleware;

View file

@ -0,0 +1,166 @@
// # SpamPrevention Middleware
// Usage: spamPrevention
// After:
// Before:
// App: Admin|Blog|API
//
// Helpers to handle spam detection on signin, forgot password, and protected pages.
var _ = require('lodash'),
errors = require('../errors'),
config = require('../config'),
loginSecurity = [],
forgottenSecurity = [],
protectedSecurity = [],
spamPrevention;
spamPrevention = {
/*jslint unparam:true*/
// limit signin requests to ten failed requests per IP per hour
signin: function (req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
deniedRateLimit = '',
ipCount = '',
message = 'Too many attempts.',
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') {
return next();
} else {
return next(new errors.BadRequestError('No username.'));
}
// filter entries that are older than rateSigninPeriod
loginSecurity = _.filter(loginSecurity, function (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) {
errors.logError(
'Only ' + rateSigninAttempts + ' tries per IP address every ' + rateSigninPeriod + ' seconds.',
'Too many login attempts.'
);
message += rateSigninPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
return next(new errors.UnauthorizedError(message));
}
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
forgotten: function (req, res, next) {
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 = '',
message = 'Too many attempts.',
index = _.findIndex(forgottenSecurity, function (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('No email.'));
}
// filter entries that are older than rateForgottenPeriod
forgottenSecurity = _.filter(forgottenSecurity, function (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) {
errors.logError(
'Only ' + rateForgottenAttempts + ' forgotten password attempts per email every ' +
rateForgottenPeriod + ' seconds.',
'Forgotten password reset attempt failed'
);
}
if (deniedRateLimit) {
errors.logError(
'Only ' + rateForgottenAttempts + ' tries per IP address every ' + rateForgottenPeriod + ' seconds.',
'Forgotten password reset attempt failed'
);
}
if (deniedEmailRateLimit || deniedRateLimit) {
message += rateForgottenPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
return next(new errors.UnauthorizedError(message));
}
next();
},
protected: function (req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
rateProtectedAttempts = config.rateProtectedAttempts || 10,
ipCount = '',
message = 'Too many attempts.',
deniedRateLimit = '',
password = req.body.password;
if (password) {
protectedSecurity.push({ip: remoteAddress, time: currentTime});
} else {
res.error = {
message: 'No password entered'
};
return next();
}
// filter entries that are older than rateProtectedPeriod
protectedSecurity = _.filter(protectedSecurity, function (logTime) {
return (logTime.time + rateProtectedPeriod > currentTime);
});
ipCount = _.chain(protectedSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
if (deniedRateLimit) {
errors.logError(
'Only ' + rateProtectedAttempts + ' tries per IP address every ' + rateProtectedPeriod + ' seconds.',
'Too many login attempts.'
);
message += rateProtectedPeriod === 3600 ? ' Please wait 1 hour.' : ' Please try again later';
res.error = {
message: message
};
}
return next();
},
resetCounter: function (email) {
loginSecurity = _.filter(loginSecurity, function (logTime) {
return (logTime.email !== email);
});
}
};
module.exports = spamPrevention;

View file

@ -69,7 +69,7 @@ apiRoutes = function (middleware) {
// ## Authentication
router.post('/authentication/passwordreset',
middleware.spamForgottenPrevention,
middleware.spamPrevention.forgotten,
api.http(api.authentication.generateResetToken)
);
router.put('/authentication/passwordreset', api.http(api.authentication.resetPassword));
@ -78,7 +78,7 @@ apiRoutes = function (middleware) {
router.post('/authentication/setup', api.http(api.authentication.setup));
router.get('/authentication/setup', api.http(api.authentication.isSetup));
router.post('/authentication/token',
middleware.spamSigninPrevention,
middleware.spamPrevention.signin,
middleware.addClientSecret,
middleware.authenticateClient,
middleware.generateAccessToken

View file

@ -41,7 +41,7 @@ frontendRoutes = function (middleware) {
)
.post(
middleware.isPrivateSessionAuth,
middleware.spamProtectedPrevention,
middleware.spamPrevention.protected,
middleware.authenticateProtection,
frontend.private
);

View file

@ -0,0 +1,221 @@
/*globals describe, beforeEach, afterEach, it*/
/*jshint expr:true*/
var should = require('should'),
sinon = require('sinon'),
middleware = require('../../../server/middleware').middleware;
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;
});
});
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';
middleware.spamPrevention.signin(req, null, next);
next.calledOnce.should.be.true;
done();
});
it ('creates a BadRequestError when there\'s no username', function (done) {
req.body = {};
middleware.spamPrevention.signin(req, null, spyNext);
should.exist(error);
error.should.be.a.BadRequestError;
done();
});
it ('rate limits after 10 attempts', function (done) {
for (var ndx = 0; ndx < 10; ndx = ndx + 1) {
middleware.spamPrevention.signin(req, null, spyNext);
}
middleware.spamPrevention.signin(req, null, spyNext);
should.exist(error);
error.should.be.a.UnauthorizedError;
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) {
middleware.spamPrevention.signin(req, null, spyNext);
}
middleware.spamPrevention.signin(req, null, spyNext);
error.should.be.a.UnauthorizedError;
error = null;
// fast forward 1 hour
process.hrtime.restore();
stub = sinon.stub(process, 'hrtime', function () {
return [3700000, 10];
});
middleware.spamPrevention.signin(req, null, spyNext);
should(error).equal(undefined);
spyNext.should.be.calledOnce;
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: [{}]
};
middleware.spamPrevention.forgotten(req, null, spyNext);
error.should.be.a.BadRequestError;
done();
});
it ('creates an unauthorized error after 5 attempts with same email', function (done) {
for (var ndx = 0; ndx < 6; ndx = ndx + 1) {
middleware.spamPrevention.forgotten(req, null, spyNext);
}
middleware.spamPrevention.forgotten(req, null, spyNext);
error.should.be.a.UnauthorizedError;
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}
];
middleware.spamPrevention.forgotten(req, null, spyNext);
}
middleware.spamPrevention.forgotten(req, null, spyNext);
error.should.be.a.UnauthorizedError;
done();
});
});
describe('protected', function () {
var res;
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 = {};
middleware.spamPrevention.protected(req, res, spyNext);
res.error.message.should.equal('No password entered');
spyNext.should.be.calledOnce;
done();
});
it ('sets and error message after 10 tries', function (done) {
var ndx;
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
middleware.spamPrevention.protected(req, res, spyNext);
}
should.not.exist(res.error);
middleware.spamPrevention.protected(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) {
middleware.spamPrevention.protected(req, res, spyNext);
}
should.exist(res.error);
process.hrtime.restore();
stub = sinon.stub(process, 'hrtime', function () {
return [3610000, 10];
});
res = sinon.spy();
middleware.spamPrevention.protected(req, res, spyNext);
should.not.exist(res.error);
process.hrtime.restore();
done();
});
});
});