feat: configurable rate limiting

pull/241/head
xwashere 8 months ago
parent 3d0a6eb794
commit c648533469
No known key found for this signature in database
GPG Key ID: 042F8BFA1B0EF93B

@ -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;
}

@ -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<string, (req: Request, res: Response, next: NextFunction) => 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)!;
}
};

@ -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' });

@ -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!');

42
common/types.d.ts vendored

@ -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;
}
}
/**

@ -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);

12
package-lock.json generated

@ -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",

@ -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",

@ -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')
Loading…
Cancel
Save