mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Added test email rate limiting (#17505)
refs https://github.com/TryGhost/Product/issues/3651 - This is a security fix that addresses an issue causing malicious users to abuse the test / preview email API endpoint. - We have multiple procedures in place now to limit such users. - First, we now only allow one email address to be passed into the `sendTestEmail` method. This method only have one purpose, which is to compliment the test email functionality within the Editor in Admin and therefore have no reason to send to more than one email address at a time. - We then add an additional rate limiter to prevent a user from making multiple requests, eg via a script. - The new imposed limit is 10 test emails per hour.
This commit is contained in:
parent
b77521ece9
commit
0029c444ad
10 changed files with 158 additions and 4 deletions
|
@ -314,7 +314,8 @@ module.exports = function apiRoutes() {
|
|||
|
||||
// ## Email Preview
|
||||
router.get('/email_previews/posts/:id', mw.authAdminApi, http(api.email_previews.read));
|
||||
router.post('/email_previews/posts/:id', mw.authAdminApi, http(api.email_previews.sendTestEmail));
|
||||
// preview sending have an additional rate limiter to prevent abuse
|
||||
router.post('/email_previews/posts/:id', shared.middleware.brute.previewEmailLimiter, mw.authAdminApi, http(api.email_previews.sendTestEmail));
|
||||
|
||||
// ## Emails
|
||||
router.get('/emails', mw.authAdminApi, http(api.emails.browse));
|
||||
|
|
|
@ -21,7 +21,8 @@ const messages = {
|
|||
context: 'Too many login attempts.'
|
||||
},
|
||||
tooManyAttempts: 'Too many attempts.',
|
||||
webmentionsBlock: 'Too many mention 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 || {};
|
||||
|
@ -31,6 +32,7 @@ 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;
|
||||
|
@ -43,6 +45,7 @@ let membersAuthInstance;
|
|||
let membersAuthEnumerationInstance;
|
||||
let userResetInstance;
|
||||
let contentApiKeyInstance;
|
||||
let emailPreviewBlockInstance;
|
||||
|
||||
const spamConfigKeys = ['freeRetries', 'minWait', 'maxWait', 'lifetime'];
|
||||
|
||||
|
@ -152,6 +155,32 @@ const webmentionsBlock = () => {
|
|||
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');
|
||||
|
@ -349,6 +378,7 @@ module.exports = {
|
|||
privateBlog: privateBlog,
|
||||
contentApiKey: contentApiKey,
|
||||
webmentionsBlock: webmentionsBlock,
|
||||
emailPreviewBlock: emailPreviewBlock,
|
||||
reset: () => {
|
||||
store = undefined;
|
||||
memoryStore = undefined;
|
||||
|
|
|
@ -117,5 +117,18 @@ module.exports = {
|
|||
return _next('webmention_blocked');
|
||||
}
|
||||
})(req, res, next);
|
||||
},
|
||||
|
||||
/**
|
||||
* Blocks preview email spam
|
||||
*/
|
||||
|
||||
previewEmailLimiter(req, res, next) {
|
||||
return spamPrevention.emailPreviewBlock().getMiddleware({
|
||||
ignoreIP: false,
|
||||
key(_req, _res, _next) {
|
||||
return _next('preview_email_blocked');
|
||||
}
|
||||
})(req, res, next);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -108,6 +108,12 @@
|
|||
"maxWait": 100,
|
||||
"lifetime": 1000,
|
||||
"freeRetries": 100
|
||||
},
|
||||
"email_preview_block": {
|
||||
"minWait": 360000,
|
||||
"maxWait": 360000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 10
|
||||
}
|
||||
},
|
||||
"caching": {
|
||||
|
|
|
@ -49,6 +49,12 @@
|
|||
"maxWait": 100000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 3
|
||||
},
|
||||
"email_preview_block": {
|
||||
"minWait": 360000,
|
||||
"maxWait": 360000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 10
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
|
|
|
@ -50,8 +50,13 @@
|
|||
"maxWait": 100000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 3
|
||||
},
|
||||
"email_preview_block": {
|
||||
"minWait": 360000,
|
||||
"maxWait": 360000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 10
|
||||
}
|
||||
|
||||
},
|
||||
"privacy": {
|
||||
"useTinfoil": true,
|
||||
|
|
|
@ -49,6 +49,12 @@
|
|||
"maxWait": 100000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 3
|
||||
},
|
||||
"email_preview_block": {
|
||||
"minWait": 360000,
|
||||
"maxWait": 360000,
|
||||
"lifetime": 3600,
|
||||
"freeRetries": 10
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
// Decided to have this test separately from the other email preview tests since the rate limiter would interfere with the other tests
|
||||
|
||||
const {agentProvider, fixtureManager, mockManager, configUtils} = require('../../utils/e2e-framework');
|
||||
const sinon = require('sinon');
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
|
||||
async function allSettled() {
|
||||
await DomainEvents.allSettled();
|
||||
}
|
||||
|
||||
describe('Rate limiter', function () {
|
||||
let agent;
|
||||
|
||||
afterEach(function () {
|
||||
mockManager.restore();
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
mockManager.mockMailgun();
|
||||
});
|
||||
|
||||
before(async function () {
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
await fixtureManager.init('users', 'newsletters', 'posts');
|
||||
await agent.loginAsOwner();
|
||||
});
|
||||
|
||||
it('is rate limited against spammmer requests', async function () {
|
||||
const testEmailSpamBlock = configUtils.config.get('spam').email_preview_block;
|
||||
const requests = [];
|
||||
for (let i = 0; i < testEmailSpamBlock.freeRetries + 1; i += 1) {
|
||||
const req = await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
});
|
||||
requests.push(req);
|
||||
}
|
||||
await Promise.all(requests);
|
||||
|
||||
await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
})
|
||||
.expectStatus(429);
|
||||
|
||||
await allSettled();
|
||||
});
|
||||
});
|
|
@ -4,7 +4,8 @@ const tpl = require('@tryghost/tpl');
|
|||
const messages = {
|
||||
postNotFound: 'Post not found.',
|
||||
noEmailsProvided: 'No emails provided.',
|
||||
emailNotFound: 'Email not found.'
|
||||
emailNotFound: 'Email not found.',
|
||||
tooManyEmailsProvided: 'Too many emails provided. Maximum of 1 test email can be sent at once.'
|
||||
};
|
||||
|
||||
class EmailController {
|
||||
|
@ -67,6 +68,13 @@ class EmailController {
|
|||
});
|
||||
}
|
||||
|
||||
// test emails are limited to 1
|
||||
if (emails.length > 1) {
|
||||
throw new errors.ValidationError({
|
||||
message: tpl(messages.tooManyEmailsProvided)
|
||||
});
|
||||
}
|
||||
|
||||
await this.service.sendTestEmail(post, newsletter, segment, emails);
|
||||
}
|
||||
|
||||
|
|
|
@ -230,6 +230,34 @@ describe('Email Controller', function () {
|
|||
});
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it('throw if more than one email is provided', async function () {
|
||||
const service = {
|
||||
sendTestEmail: () => {
|
||||
return Promise.resolve({id: 'mail@id'});
|
||||
}
|
||||
};
|
||||
|
||||
const controller = new EmailController(service, {
|
||||
models: {
|
||||
Post: createModelClass({
|
||||
findOne: {
|
||||
title: 'Post title'
|
||||
}
|
||||
}),
|
||||
Newsletter: createModelClass()
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(controller.sendTestEmail({
|
||||
options: {},
|
||||
data: {
|
||||
id: '123',
|
||||
newsletter: 'newsletter-slug',
|
||||
emails: ['example@example.com', 'example2@example.com']
|
||||
}
|
||||
}), /Too many emails provided. Maximum of 1 test email can be sent at once./);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryFailedEmail', function () {
|
||||
|
|
Loading…
Add table
Reference in a new issue