From c648533469ddda92621e494557023112388e943c Mon Sep 17 00:00:00 2001 From: xwashere Date: Wed, 25 Oct 2023 10:25:06 -0400 Subject: [PATCH] feat: configurable rate limiting --- backend/UserConfig.ts | 11 ++++++++++ backend/ratelimit.ts | 41 +++++++++++++++++++++++++++++++++++ backend/routers/api.ts | 5 +++-- backend/routers/index.ts | 3 ++- common/types.d.ts | 42 +++++++++++++++++++++++++++++++++++ frontend/setup.mts | 47 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 12 ++++++++++ package.json | 1 + views/setup.pug | 16 ++++++++++++++ 9 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 backend/ratelimit.ts diff --git a/backend/UserConfig.ts b/backend/UserConfig.ts index 0a90031..de49982 100644 --- a/backend/UserConfig.ts +++ b/backend/UserConfig.ts @@ -59,6 +59,10 @@ const Checkers: UserConfigTypeChecker = { password: basicStringChecker, database: basicStringChecker } + }, + + rateLimit: { + endpoint: (val) => val == null || (val != null && (numChecker(val.requests) && numChecker(val.duration))) } }; @@ -107,6 +111,13 @@ export class UserConfig { if (!Checkers.sql.mySql.database(config.sql.mySql.database)) throw new Error('Invalid MySql Database'); } + // * optional rate limit config + if (config.rateLimit != null) { + if (!Checkers.rateLimit.endpoint(config.rateLimit.login)) throw new Error('Invalid rate limit configuration'); + if (!Checkers.rateLimit.endpoint(config.rateLimit.upload)) throw new Error('Invalid rate limit configuration'); + if (!Checkers.rateLimit.endpoint(config.rateLimit.api)) throw new Error('Invalid rate limit configuration'); + } + // All is fine, carry on! return config; } diff --git a/backend/ratelimit.ts b/backend/ratelimit.ts new file mode 100644 index 0000000..ce2df56 --- /dev/null +++ b/backend/ratelimit.ts @@ -0,0 +1,41 @@ +import { EndpointRateLimitConfiguration } from 'ass'; +import { NextFunction, Request, Response } from 'express'; +import { rateLimit } from 'express-rate-limit'; + +/** + * map that contains rate limiter middleware for each group + */ +let rateLimiterGroups = new Map void>(); + +/** + * creates middleware for rate limiting + */ +export const rateLimiterMiddleware = (group: string, config: EndpointRateLimitConfiguration | undefined): (req: Request, res: Response, next: NextFunction) => void => { + if (rateLimiterGroups.has(group)) return rateLimiterGroups.get(group)!; + + if (config == null) { // config might be null if the user doesnt want a rate limit + rateLimiterGroups.set(group, (req, res, next) => { + next(); + }); + + return rateLimiterGroups.get(group)!; + } else { + rateLimiterGroups.set(group, rateLimit({ + limit: config.requests, + windowMs: config.duration * 1000, + skipFailedRequests: true, + legacyHeaders: false, + standardHeaders: "draft-7", + keyGenerator: (req, res) => { + return req.ip || 'disconnected'; + }, + handler: (req, res) => { + res.status(429); + res.contentType('json'); + res.send('{"success":false,"message":"Rate limit exceeded, try again later"}'); + } + })); + + return rateLimiterGroups.get(group)!; + } +}; \ No newline at end of file diff --git a/backend/routers/api.ts b/backend/routers/api.ts index fd7dee0..9dd7bf0 100644 --- a/backend/routers/api.ts +++ b/backend/routers/api.ts @@ -8,6 +8,7 @@ import { log } from '../log'; import { nanoid } from '../generators'; import { UserConfig } from '../UserConfig'; import { MySql } from '../sql/mysql'; +import { rateLimiterMiddleware } from '../ratelimit'; const router = Router({ caseSensitive: true }); @@ -38,7 +39,7 @@ router.post('/setup', BodyParserJson(), async (req, res) => { }); // User login -router.post('/login', BodyParserJson(), (req, res) => { +router.post('/login', rateLimiterMiddleware("login", UserConfig.config.rateLimit?.login), BodyParserJson(), (req, res) => { const { username, password } = req.body; data.getAll('users') @@ -68,7 +69,7 @@ router.post('/login', BodyParserJson(), (req, res) => { }); // todo: authenticate API endpoints -router.post('/user', BodyParserJson(), async (req, res) => { +router.post('/user', rateLimiterMiddleware("api", UserConfig.config.rateLimit?.api), BodyParserJson(), async (req, res) => { if (!UserConfig.ready) return res.status(409).json({ success: false, message: 'User config not ready' }); diff --git a/backend/routers/index.ts b/backend/routers/index.ts index cf44fbd..e49df5e 100644 --- a/backend/routers/index.ts +++ b/backend/routers/index.ts @@ -12,6 +12,7 @@ import { App } from '../app'; import { random } from '../generators'; import { UserConfig } from '../UserConfig'; import { getFileS3, uploadFileS3 } from '../s3'; +import { rateLimiterMiddleware } from '../ratelimit'; const router = Router({ caseSensitive: true }); @@ -29,7 +30,7 @@ bb.extend(router, { router.get('/', (req, res) => UserConfig.ready ? res.render('index', { version: App.pkgVersion }) : res.redirect('/setup')); // Upload flow -router.post('/', async (req, res) => { +router.post('/', rateLimiterMiddleware("upload", UserConfig.config.rateLimit?.upload), async (req, res) => { // Check user config if (!UserConfig.ready) return res.status(500).type('text').send('Configuration missing!'); diff --git a/common/types.d.ts b/common/types.d.ts index 3f04c21..d9ca35f 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -25,6 +25,8 @@ declare module 'ass' { s3?: S3Configuration; sql?: SqlConfiguration; + + rateLimit?: RateLimitConfiguration; } interface S3Configuration { @@ -57,6 +59,43 @@ declare module 'ass' { database: string; } } + + /** + * rate limiter configuration + * @since 0.15.0 + */ + interface RateLimitConfiguration { + /** + * rate limit for the login endpoints + */ + login?: EndpointRateLimitConfiguration; + + /** + * rate limit for parts of the api not covered by other rate limits + */ + api?: EndpointRateLimitConfiguration; + + /** + * rate limit for file uploads + */ + upload?: EndpointRateLimitConfiguration; + } + + /** + * rate limiter per-endpoint configuration + * @since 0.15.0 + */ + interface EndpointRateLimitConfiguration { + /** + * maximum number of requests per duration + */ + requests: number; + + /** + * rate limiting window in seconds + */ + duration: number; + } interface UserConfigTypeChecker { uploadsDir: (val: any) => boolean; @@ -81,6 +120,9 @@ declare module 'ass' { database: (val: any) => boolean; } } + rateLimit: { + endpoint: (val: any) => boolean; + } } /** diff --git a/frontend/setup.mts b/frontend/setup.mts index 1ef28a2..fe2d043 100644 --- a/frontend/setup.mts +++ b/frontend/setup.mts @@ -4,6 +4,26 @@ import { IdType, UserConfiguration } from 'ass'; const genericErrorAlert = () => alert('An error occured, please check the console for details'); const errAlert = (logTitle: string, err: any, stream: 'error' | 'warn' = 'error') => (console[stream](logTitle, err), genericErrorAlert()); const errReset = (message: string, element: SlButton) => (element.disabled = false, alert(message)); +const genericRateLimit = (config: object, category: string, submitButton: SlButton, requests: SlInput, time: SlInput) => { + if ((requests.value || time.value) != '') { + if (requests.value == '') { + errReset(`No count for ${category} rate limit`, submitButton); + return true; // this should probably be false but this lets us chain this until we see an error + } + + if (time.value == '') { + errReset(`No time for ${category} rate limit`, submitButton); + return true; + } + + (config as any)[category] = { + requests: parseInt(requests.value), + duration: parseInt(time.value), + }; + } + + return false; +}; // * Wait for the document to be ready document.addEventListener('DOMContentLoaded', () => { @@ -29,6 +49,13 @@ document.addEventListener('DOMContentLoaded', () => { userUsername: document.querySelector('#user-username') as SlInput, userPassword: document.querySelector('#user-password') as SlInput, + ratelimitLoginRequests: document.querySelector('#ratelimit-login-requests') as SlInput, + ratelimitLoginTime: document.querySelector('#ratelimit-login-time') as SlInput, + ratelimitApiRequests: document.querySelector('#ratelimit-api-requests') as SlInput, + ratelimitApiTime: document.querySelector('#ratelimit-api-time') as SlInput, + ratelimitUploadRequests: document.querySelector('#ratelimit-upload-requests') as SlInput, + ratelimitUploadTime: document.querySelector('#ratelimit-upload-time') as SlInput, + submitButton: document.querySelector('#submit') as SlButton, }; @@ -72,6 +99,26 @@ document.addEventListener('DOMContentLoaded', () => { }; } + // append rate limit config, if specified + if (( + Elements.ratelimitLoginRequests.value + || Elements.ratelimitLoginTime.value + || Elements.ratelimitUploadRequests.value + || Elements.ratelimitUploadTime.value + || Elements.ratelimitApiRequests.value + || Elements.ratelimitApiTime.value) != '' + ) { + if (!config.rateLimit) config.rateLimit = {}; + + if ( + genericRateLimit(config.rateLimit, 'login', Elements.submitButton, Elements.ratelimitLoginRequests, Elements.ratelimitLoginTime) + || genericRateLimit(config.rateLimit, 'api', Elements.submitButton, Elements.ratelimitApiRequests, Elements.ratelimitApiTime) + || genericRateLimit(config.rateLimit, 'upload', Elements.submitButton, Elements.ratelimitUploadRequests, Elements.ratelimitUploadTime) + ) { + return; + } + } + // ! Make sure the admin user fields are set if (Elements.userUsername.value == null || Elements.userUsername.value === '') return errReset('Admin username is required!', Elements.submitButton); diff --git a/package-lock.json b/package-lock.json index ef902eb..4fe2967 100755 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "cssnano": "^6.0.1", "express": "^4.18.2", "express-busboy": "^10.1.0", + "express-rate-limit": "^7.1.2", "express-session": "^1.17.3", "ffmpeg-static": "^5.2.0", "fs-extra": "^11.1.1", @@ -3960,6 +3961,17 @@ "uuid": "^8.3.2" } }, + "node_modules/express-rate-limit": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.1.2.tgz", + "integrity": "sha512-uvkFt5JooXDhUhrfgqXLyIsAMRCtU1o8W/p0Q2p5U2ude7fEOfFaP0kSYbHOHmPbA9ZEm1JqrRne3vL9pVCBXA==", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, "node_modules/express-session": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", diff --git a/package.json b/package.json index 254bd66..afaf7ba 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "cssnano": "^6.0.1", "express": "^4.18.2", "express-busboy": "^10.1.0", + "express-rate-limit": "^7.1.2", "express-session": "^1.17.3", "ffmpeg-static": "^5.2.0", "fs-extra": "^11.1.1", diff --git a/views/setup.pug b/views/setup.pug index acff4db..6446068 100644 --- a/views/setup.pug +++ b/views/setup.pug @@ -55,6 +55,22 @@ block content sl-input#mysql-password(type='password' placeholder='super-secure' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') h3.setup-text-item-title Database sl-input#mysql-database(type='text' placeholder='assdb' clearable): sl-icon(slot='prefix' name='fas-database' library='fa') + + //- * Rate Limits + h2.setup-text-section-header.mt-4 Rate Limits #[span.setup-text-optional optional] + .setup-panel + h3.setup-text-item-title Generic API - Requests + sl-input#ratelimit-api-requests(type='text' placeholder='120' clearable): sl-icon(slot='prefix' name='fas-hashtag' library='fa') + h3.setup-text-item-title Generic API - Seconds per reset + sl-input#ratelimit-api-time(type='text' placeholder='60' clearable): sl-icon(slot='prefix' name='fas-clock' library='fa') + h3.setup-text-item-title Login - Requests + sl-input#ratelimit-login-requests(type='text' placeholder='5' clearable): sl-icon(slot='prefix' name='fas-hashtag' library='fa') + h3.setup-text-item-title Login - Seconds per reset + sl-input#ratelimit-login-time(type='text' placeholder='30' clearable): sl-icon(slot='prefix' name='fas-clock' library='fa') + h3.setup-text-item-title File upload - Requests + sl-input#ratelimit-upload-requests(type='text' placeholder='120' clearable): sl-icon(slot='prefix' name='fas-hashtag' library='fa') + h3.setup-text-item-title File upload - Seconds per reset + sl-input#ratelimit-upload-time(type='text' placeholder='60' clearable): sl-icon(slot='prefix' name='fas-clock' library='fa') sl-button.w-32.mt-2.self-center#submit(type='primary' submit) Submit script(src='/setup/ui.js') \ No newline at end of file