feat: RateLimiter custom middleware class

this adds new production dependency rate-limiter-flexible

this deprecates old rateLimits option in config

to use the new rate limiters, the new option is named rateLimiters and
rateLimitersWhitelist
please consult config.sample.js

rate limiters will also be now processed before any other middlewares,
as only makes sense
This commit is contained in:
Bobby Wibowo 2022-07-12 08:48:09 +07:00
parent 26ae853362
commit 79631ce624
No known key found for this signature in database
GPG Key ID: 51C3A1E1E22D26CF
5 changed files with 113 additions and 57 deletions

View File

@ -206,42 +206,20 @@ module.exports = {
trustProxy: true, trustProxy: true,
/* /*
Rate limits. Rate limiters.
Please be aware that these apply to all users, including site owners. https://github.com/animir/node-rate-limiter-flexible/wiki/Memory
https://github.com/nfriedly/express-rate-limit/tree/v6.3.0#configuration
*/ */
rateLimits: [ rateLimiters: [
{
// 10 requests in 1 second
routes: [
'/api/'
],
config: {
windowMs: 1000,
max: 10,
legacyHeaders: true,
standardHeaders: true,
message: {
success: false,
description: 'Rate limit reached, please try again in a while.'
}
}
},
{ {
// 2 requests in 5 seconds // 2 requests in 5 seconds
routes: [ routes: [
// If multiple routes, they will share the same points pool
'/api/login', '/api/login',
'/api/register' '/api/register'
], ],
config: { options: {
windowMs: 5 * 1000, points: 2,
max: 2, duration: 5
legacyHeaders: true,
standardHeaders: true,
message: {
success: false,
description: 'Rate limit reached, please try again in 5 seconds.'
}
} }
}, },
{ {
@ -249,11 +227,9 @@ module.exports = {
routes: [ routes: [
'/api/album/zip' '/api/album/zip'
], ],
config: { options: {
windowMs: 30 * 1000, points: 6,
max: 6, duration: 30
legacyHeaders: true,
standardHeaders: true
} }
}, },
{ {
@ -261,19 +237,35 @@ module.exports = {
routes: [ routes: [
'/api/tokens/change' '/api/tokens/change'
], ],
config: { options: {
windowMs: 60 * 1000, points: 1,
max: 1, duration: 60
legacyHeaders: true, }
standardHeaders: true, },
message: { /*
success: false, Routes, whose scope would have encompassed other routes that have their own rate limit pools,
description: 'Rate limit reached, please try again in 60 seconds.' must only be set after said routes, otherwise their rate limit pools will never trigger.
} i.e. since /api/ encompass all other /api/* routes, it must be set last
*/
{
// 10 requests in 1 second
routes: [
'/api/'
],
options: {
points: 10,
duration: 1
} }
} }
], ],
/*
Whitelisted IP addresses for rate limiters.
*/
rateLimitersWhitelist: [
'127.0.0.1'
],
/* /*
Uploads config. Uploads config.
*/ */

View File

@ -0,0 +1,56 @@
const { RateLimiterMemory } = require('rate-limiter-flexible')
const ClientError = require('./../utils/ClientError')
class RateLimiter {
#requestKey
#whitelistedKeys
rateLimiterMemory
constructor (requestKey, options = {}, whitelistedKeys) {
if (typeof options.points !== 'number' || typeof options.duration !== 'number') {
throw new Error('Points and Duration must be set with numbers in options')
}
if (whitelistedKeys && typeof whitelistedKeys instanceof Set) {
throw new TypeError('Whitelisted keys must be a Set')
}
this.#requestKey = requestKey
this.#whitelistedKeys = new Set(whitelistedKeys)
this.rateLimiterMemory = new RateLimiterMemory(options)
}
async #middleware (req, res, next) {
if (res.locals.rateLimit) return
// If unset, assume points pool is shared to all visitors of each route
const key = this.#requestKey ? req[this.#requestKey] : req.path
if (this.#whitelistedKeys.has(key)) {
// Set the Response local variable for earlier bypass in any subsequent RateLimit middlewares
res.locals.rateLimit = 'BYPASS'
return
}
// Always consume only 1 point
await this.rateLimiterMemory.consume(key, 1)
.then(result => {
res.locals.rateLimit = result
res.set('Retry-After', String(result.msBeforeNext / 1000))
res.set('X-RateLimit-Limit', String(this.rateLimiterMemory._points))
res.set('X-RateLimit-Remaining', String(result.remainingPoints))
res.set('X-RateLimit-Reset', String(new Date(Date.now() + result.msBeforeNext)))
})
.catch(reject => {
// Re-throw with ClientError
throw new ClientError('Rate limit reached, please try again in a while.', { statusCode: 429 })
})
}
get middleware () {
return this.#middleware.bind(this)
}
}
module.exports = RateLimiter

View File

@ -14,7 +14,6 @@ const helmet = require('helmet')
const HyperExpress = require('hyper-express') const HyperExpress = require('hyper-express')
const LiveDirectory = require('live-directory') const LiveDirectory = require('live-directory')
const NodeClam = require('clamscan') const NodeClam = require('clamscan')
// const rateLimit = require('express-rate-limit') // FIXME: Find alternative
const { accessSync, constants } = require('fs') const { accessSync, constants } = require('fs')
// Check required config files // Check required config files
@ -46,6 +45,7 @@ const utils = require('./controllers/utilsController')
// Custom middlewares // Custom middlewares
const NunjucksRenderer = require('./controllers/middlewares/nunjucksRenderer') const NunjucksRenderer = require('./controllers/middlewares/nunjucksRenderer')
const RateLimiter = require('./controllers/middlewares/rateLimiter')
// const ServeStatic = require('./controllers/middlewares/serveStatic') // TODO // const ServeStatic = require('./controllers/middlewares/serveStatic') // TODO
// Routes // Routes
@ -57,6 +57,21 @@ const player = require('./routes/player')
const isDevMode = process.env.NODE_ENV === 'development' const isDevMode = process.env.NODE_ENV === 'development'
// Rate limiters
if (Array.isArray(config.rateLimiters)) {
let whitelistedKeys
if (Array.isArray(config.rateLimitersWhitelist)) {
whitelistedKeys = new Set(config.rateLimitersWhitelist)
}
for (const rateLimit of config.rateLimiters) {
// Init RateLimiter using Request.ip as key
const rateLimiterInstance = new RateLimiter('ip', rateLimit.options, whitelistedKeys)
for (const route of rateLimit.routes) {
safe.use(route, rateLimiterInstance.middleware)
}
}
}
// Helmet security headers // Helmet security headers
if (config.helmet instanceof Object) { if (config.helmet instanceof Object) {
// If an empty object, simply do not use Helmet // If an empty object, simply do not use Helmet
@ -111,19 +126,6 @@ const initLiveDirectory = (options = {}) => {
return new LiveDirectory(options) return new LiveDirectory(options)
} }
// Configure rate limits (disabled during development)
// FIXME: express-rate-limit does not work with hyper-express, find alternative
/*
if (!isDevMode && Array.isArray(config.rateLimits) && config.rateLimits.length) {
for (const _rateLimit of config.rateLimits) {
const limiter = rateLimit(_rateLimit.config)
for (const route of _rateLimit.routes) {
safe.use(route, limiter)
}
}
}
*/
const cdnPages = [...config.pages] const cdnPages = [...config.pages]
// Defaults to no-op // Defaults to no-op

View File

@ -51,6 +51,7 @@
"node-fetch": "~2.6.7", "node-fetch": "~2.6.7",
"nunjucks": "~3.2.3", "nunjucks": "~3.2.3",
"randomstring": "~1.2.2", "randomstring": "~1.2.2",
"rate-limiter-flexible": "^2.3.7",
"search-query-parser": "~1.6.0", "search-query-parser": "~1.6.0",
"sharp": "~0.30.7", "sharp": "~0.30.7",
"systeminformation": "5.11.23" "systeminformation": "5.11.23"

View File

@ -5373,6 +5373,11 @@ range-parser@^1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
rate-limiter-flexible@^2.3.7:
version "2.3.7"
resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-2.3.7.tgz#c23e1f818a1575f1de1fd173437f4072125e1615"
integrity sha512-dmc+J/IffVBvHlqq5/XClsdLdkOdQV/tjrz00cwneHUbEDYVrf4aUDAyR4Jybcf2+Vpn4NwoVrnnAyt/D0ciWw==
rc@^1.2.7, rc@^1.2.8: rc@^1.2.7, rc@^1.2.8:
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"