Implemented mentions rate limiter (#16223)

closes https://github.com/TryGhost/Team/issues/2419

- adds a rate limiter implementation to the mentions receiving
endpoint.
- Current configuration is `{"minWait": 10,
             "maxWait": 100,
             "lifetime": 1000,
             "freeRetries": 100}` which is still very open and almost unrestricted. 
- currently makes use of database storage to track the limits, but can be relatively easily swapped out to something eg Redis should we find this endpoint getting hit too often and maliciously.
This commit is contained in:
Ronald Langeveld 2023-02-09 14:57:48 +08:00 committed by GitHub
parent 6c97edec25
commit 9fc13cfe65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 103 additions and 2 deletions

View File

@ -20,7 +20,8 @@ const messages = {
error: 'Only {rateSigninAttempts} tries per IP address every {rateSigninPeriod} seconds.',
context: 'Too many login attempts.'
},
tooManyAttempts: 'Too many attempts.'
tooManyAttempts: 'Too many attempts.',
webmentionsBlock: 'Too many mention attempts'
};
let spamPrivateBlock = spam.private_block || {};
let spamGlobalBlock = spam.global_block || {};
@ -29,12 +30,14 @@ 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 store;
let memoryStore;
let privateBlogInstance;
let globalResetInstance;
let globalBlockInstance;
let webmentionsBlockInstance;
let userLoginInstance;
let membersAuthInstance;
let membersAuthEnumerationInstance;
@ -123,6 +126,32 @@ const globalReset = () => {
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 membersAuth = () => {
const ExpressBrute = require('express-brute');
const BruteKnex = require('brute-knex');
@ -319,6 +348,7 @@ module.exports = {
userReset: userReset,
privateBlog: privateBlog,
contentApiKey: contentApiKey,
webmentionsBlock: webmentionsBlock,
reset: () => {
store = undefined;
memoryStore = undefined;

View File

@ -104,5 +104,18 @@ module.exports = {
*/
membersAuthEnumeration(req, res, next) {
return spamPrevention.membersAuthEnumeration().prevent(req, res, next);
},
/**
* Blocks webmention spam
*/
webmentionsLimiter(req, res, next) {
return spamPrevention.webmentionsBlock().getMiddleware({
ignoreIP: false,
key(_req, _res, _next) {
return _next('webmention_blocked');
}
})(req, res, next);
}
};

View File

@ -11,6 +11,9 @@ module.exports = function apiRoutes() {
// shouldn't be cached
router.use(shared.middleware.cacheControl('private'));
// rate limiter
router.use(shared.middleware.brute.webmentionsLimiter);
// Webmentions
router.post('/receive', bodyParser.urlencoded({extended: true, limit: '5mb'}), http(api.mentions.receive));

View File

@ -102,6 +102,12 @@
"maxWait": 43200000,
"lifetime": 43200,
"freeRetries": 8
},
"webmentions_block": {
"minWait": 10,
"maxWait": 100,
"lifetime": 1000,
"freeRetries": 100
}
},
"caching": {

View File

@ -43,6 +43,12 @@
"maxWait": 3600000,
"lifetime": 3600,
"freeRetries":99
},
"webmentions_block": {
"minWait": 10,
"maxWait": 100,
"lifetime": 1000,
"freeRetries": 100
}
},
"privacy": {

View File

@ -1,4 +1,10 @@
const {agentProvider, fixtureManager, mockManager, matchers, sleep} = require('../../utils/e2e-framework');
const {
agentProvider,
fixtureManager,
mockManager,
dbUtils,
configUtils
} = require('../../utils/e2e-framework');
const models = require('../../../core/server/models');
const assert = require('assert');
const urlUtils = require('../../../core/shared/url-utils');
@ -169,4 +175,41 @@ describe('Webmentions (receiving)', function () {
emailMockReceiver.sentEmailCount(0);
});
it('is rate limited against spamming mention requests', async function () {
await dbUtils.truncate('brute');
const webmentionBlock = configUtils.config.get('spam').webmentions_block;
const targetUrl = new URL(urlUtils.getSiteUrl());
const sourceUrl = new URL('http://testpage.com/external-article-2/');
const html = `
<html><head><title>Test Page</title><meta name="description" content="Test description"><meta name="author" content="John Doe"></head><body></body></html>
`;
nock(targetUrl.origin)
.head(targetUrl.pathname)
.reply(200);
nock(sourceUrl.origin)
.get(sourceUrl.pathname)
.reply(200, html, {'Content-Type': 'text/html'});
// +1 because this is a retry count, so we have one request + the retries, then blocked
for (let i = 0; i < webmentionBlock.freeRetries + 1; i++) {
await agent.post('/receive/')
.body({
source: sourceUrl.href,
target: targetUrl.href,
payload: {}
})
.expectStatus(202);
}
await agent
.post('/receive/')
.body({
source: sourceUrl.href,
target: targetUrl.href,
payload: {}
})
.expectStatus(429);
});
});