ghost/core/core/server/web/shared/middleware/api/spam-prevention.js
const moment = require('moment');
const extend = require('lodash/extend');
const pick = require('lodash/pick');
const errors = require('@tryghost/errors');
const config = require('../../../../../shared/config');
const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
let spam = config.get('spam') || {};
const messages = {
forgottenPasswordEmail: {
error: 'Only {rfa} forgotten password attempts per email every {rfp} seconds.',
context: 'Forgotten password reset attempt failed'
},
forgottenPasswordIp: {
error: 'Only {rfa} tries per IP address every {rfp} seconds.',
context: 'Forgotten password reset attempt failed'
},
tooManySigninAttempts: {
error: 'Only {rateSigninAttempts} tries per IP address every {rateSigninPeriod} seconds.',
context: 'Too many login attempts.'
},
tooManyAttempts: 'Too many attempts.',
webmentionsBlock: 'Too many mention attempts',
emailPreviewBlock: 'Only 10 test emails can be sent per hour'
};
let spamPrivateBlock = spam.private_block || {};
let spamGlobalBlock = spam.global_block || {};
let spamGlobalReset = spam.global_reset || {};
let spamUserReset = spam.user_reset || {};
let spamUserLogin = spam.user_login || {};
let spamMemberLogin = spam.member_login || {};
let spamContentApiKey = spam.content_api_key || {};
let spamWebmentionsBlock = spam.webmentions_block || {};
let spamEmailPreviewBlock = spam.email_preview_block || {};
let store;
let memoryStore;
let privateBlogInstance;
let globalResetInstance;
let globalBlockInstance;
let webmentionsBlockInstance;
let userLoginInstance;
let membersAuthInstance;
let membersAuthEnumerationInstance;
let userResetInstance;
let contentApiKeyInstance;
let emailPreviewBlockInstance;
const spamConfigKeys = ['freeRetries', 'minWait', 'maxWait', 'lifetime'];
const handleStoreError = (err) => {
const customError = new errors.InternalServerError({
message: 'Unknown error',
err: err.parent ? err.parent : err
});
// see https://github.com/AdamPflug/express-brute/issues/45
// express-brute does not always forward a callback
// we are using reset as synchronous call, so we have to log the error if it occurs
// there is no way to try/catch, because the reset operation happens asynchronous
if (!err.next) {
logging.error(err);
return;
}
err.next(customError);
};
// This locks a single endpoint based on excessive requests from an IP.
// Currently only used for auth type methods.
// We allow for a generous number of requests here to prevent communites on the same IP bing barred on account of a single user
// Defaults to 50 attempts per hour and locks the endpoint for an hour
const globalBlock = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
globalBlockInstance = globalBlockInstance || new ExpressBrute(store,
extend({
attachResetToRequest: false,
failCallback(req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: `Too many attempts try again in ${moment(nextValidRequestDate).fromNow(true)}`,
context: tpl(messages.forgottenPasswordIp.error,
{rfa: spamGlobalBlock.freeRetries + 1 || 5, rfp: spamGlobalBlock.lifetime || 60 * 60}),
help: tpl(messages.tooManyAttempts)
}));
},
handleStoreError: handleStoreError
}, pick(spamGlobalBlock, spamConfigKeys))
);
return globalBlockInstance;
};
const globalReset = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
globalResetInstance = globalResetInstance || new ExpressBrute(store,
extend({
attachResetToRequest: false,
failCallback(req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: `Too many attempts try again in ${moment(nextValidRequestDate).fromNow(true)}`,
context: tpl(messages.forgottenPasswordIp.error,
{rfa: spamGlobalReset.freeRetries + 1 || 5, rfp: spamGlobalReset.lifetime || 60 * 60}),
help: tpl(messages.forgottenPasswordIp.context)
}));
},
handleStoreError: handleStoreError
}, pick(spamGlobalReset, spamConfigKeys))
);
return globalResetInstance;
};
const webmentionsBlock = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
webmentionsBlockInstance = webmentionsBlockInstance || new ExpressBrute(store,
extend({
attachResetToRequest: false,
failCallback(req, res, next) {
return next(new errors.TooManyRequestsError({
message: messages.webmentionsBlock
}));
},
handleStoreError: handleStoreError
}, pick(spamWebmentionsBlock, spamConfigKeys))
);
return webmentionsBlockInstance;
};
const emailPreviewBlock = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
emailPreviewBlockInstance = emailPreviewBlockInstance || new ExpressBrute(store,
extend({
attachResetToRequest: false,
failCallback(req, res, next) {
return next(new errors.TooManyRequestsError({
message: messages.emailPreviewBlock
}));
},
handleStoreError: handleStoreError
}, pick(spamEmailPreviewBlock, spamConfigKeys))
);
return emailPreviewBlockInstance;
};
const membersAuth = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
if (!membersAuthInstance) {
membersAuthInstance = new ExpressBrute(store,
extend({
attachResetToRequest: true,
failCallback(req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: `Too many sign-in attempts try again in ${moment(nextValidRequestDate).fromNow(true)}`,
context: tpl(messages.tooManySigninAttempts.context),
help: tpl(messages.tooManySigninAttempts.context)
}));
},
handleStoreError: handleStoreError
}, pick(spamUserLogin, spamConfigKeys))
);
}
return membersAuthInstance;
};
/**
* This one should have higher limits because it checks across all email addresses
*/
const membersAuthEnumeration = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
if (!membersAuthEnumerationInstance) {
membersAuthEnumerationInstance = new ExpressBrute(store,
extend({
attachResetToRequest: true,
failCallback(req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: `Too many different sign-in attempts, try again in ${moment(nextValidRequestDate).fromNow(true)}`,
context: tpl(messages.tooManySigninAttempts.context),
help: tpl(messages.tooManySigninAttempts.context)
}));
},
handleStoreError: handleStoreError
}, pick(spamMemberLogin, spamConfigKeys))
);
}
return membersAuthEnumerationInstance;
};
// 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
const userLogin = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
userLoginInstance = userLoginInstance || new ExpressBrute(store,
extend({
attachResetToRequest: true,
failCallback(req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: `Too many login attempts. Please wait ${moment(nextValidRequestDate).fromNow(true)} before trying again, or reset your password.`,
context: tpl(messages.tooManySigninAttempts.context),
help: tpl(messages.tooManySigninAttempts.context)
}));
},
handleStoreError: handleStoreError
}, pick(spamUserLogin, spamConfigKeys))
);
return userLoginInstance;
};
// 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
const userReset = function userReset() {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
userResetInstance = userResetInstance || new ExpressBrute(store,
extend({
attachResetToRequest: true,
failCallback(req, res, next, nextValidRequestDate) {
return next(new errors.TooManyRequestsError({
message: `Too many password reset attempts try again in ${moment(nextValidRequestDate).fromNow(true)}`,
context: tpl(messages.forgottenPasswordEmail.error,
{rfa: spamUserReset.freeRetries + 1 || 5, rfp: spamUserReset.lifetime || 60 * 60}),
help: tpl(messages.forgottenPasswordEmail.context)
}));
},
handleStoreError: handleStoreError
}, pick(spamUserReset, spamConfigKeys))
);
return userResetInstance;
};
// 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
const privateBlog = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
const db = require('../../../../data/db');
store = store || new BruteKnex({
tablename: 'brute',
createTable: false,
knex: db.knex
});
privateBlogInstance = privateBlogInstance || new ExpressBrute(store,
extend({
attachResetToRequest: false,
failCallback(req, res, next, nextValidRequestDate) {
logging.error(new errors.TooManyRequestsError({
message: tpl(messages.tooManySigninAttempts.error,
{
rateSigninAttempts: spamPrivateBlock.freeRetries + 1 || 5,
rateSigninPeriod: spamPrivateBlock.lifetime || 60 * 60
}),
context: tpl(messages.tooManySigninAttempts.context)
}));
return next(new errors.TooManyRequestsError({
message: `Too many private sign-in attempts try again in ${moment(nextValidRequestDate).fromNow(true)}`
}));
},
handleStoreError: handleStoreError
}, pick(spamPrivateBlock, spamConfigKeys))
);
return privateBlogInstance;
};
const contentApiKey = () => {
const ExpressBrute = require('express-brute');
memoryStore = memoryStore || new ExpressBrute.MemoryStore();
contentApiKeyInstance = contentApiKeyInstance || new ExpressBrute(memoryStore,
extend({
attachResetToRequest: true,
failCallback(req, res, next) {
const err = new errors.TooManyRequestsError({
message: tpl(messages.tooManyAttempts)
});
logging.error(err);
return next(err);
},
handleStoreError: handleStoreError
}, pick(spamContentApiKey, spamConfigKeys))
);
return contentApiKeyInstance;
};
module.exports = {
globalBlock: globalBlock,
globalReset: globalReset,
userLogin: userLogin,
membersAuth: membersAuth,
membersAuthEnumeration: membersAuthEnumeration,
userReset: userReset,
privateBlog: privateBlog,
contentApiKey: contentApiKey,
webmentionsBlock: webmentionsBlock,
emailPreviewBlock: emailPreviewBlock,
reset: () => {
store = undefined;
memoryStore = undefined;
privateBlogInstance = undefined;
globalResetInstance = undefined;
globalBlockInstance = undefined;
userLoginInstance = undefined;
membersAuthInstance = undefined;
membersAuthEnumerationInstance = undefined;
userResetInstance = undefined;
contentApiKeyInstance = undefined;
spam = config.get('spam') || {};
spamPrivateBlock = spam.private_block || {};
spamGlobalBlock = spam.global_block || {};
spamGlobalReset = spam.global_reset || {};
spamUserReset = spam.user_reset || {};
spamUserLogin = spam.user_login || {};
spamMemberLogin = spam.member_login || {};
spamContentApiKey = spam.content_api_key || {};
}
};