diff --git a/api/src/app.ts b/api/src/app.ts index 4de99c25ed..3ae69a55c0 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -47,7 +47,8 @@ import { checkIP } from './middleware/check-ip'; import cors from './middleware/cors'; import errorHandler from './middleware/error-handler'; import extractToken from './middleware/extract-token'; -import rateLimiter from './middleware/rate-limiter'; +import rateLimiter from './middleware/rate-limiter-ip'; +import rateLimiterGlobal from './middleware/rate-limiter-global'; import sanitizeQuery from './middleware/sanitize-query'; import schema from './middleware/schema'; @@ -209,6 +210,9 @@ export default async function createApp(): Promise { } // use the rate limiter - all routes for now + if (env.RATE_LIMITER_GLOBAL_ENABLED === true) { + app.use(rateLimiterGlobal); + } if (env.RATE_LIMITER_ENABLED === true) { app.use(rateLimiter); } diff --git a/api/src/env.ts b/api/src/env.ts index 743737b45a..50b271df63 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -58,6 +58,7 @@ const allowedEnvironmentVars = [ 'CORS_CREDENTIALS', 'CORS_MAX_AGE', // rate limiting + 'RATE_LIMITER_GLOBAL_.+', 'RATE_LIMITER_.+', // cache 'CACHE_ENABLED', @@ -205,10 +206,15 @@ const defaults: Record = { STORAGE_LOCAL_ROOT: './uploads', RATE_LIMITER_ENABLED: false, - RATE_LIMITER_POINTS: 25, + RATE_LIMITER_POINTS: 50, RATE_LIMITER_DURATION: 1, RATE_LIMITER_STORE: 'memory', + RATE_LIMITER_GLOBAL_ENABLED: false, + RATE_LIMITER_GLOBAL_POINTS: 1000, + RATE_LIMITER_GLOBAL_DURATION: 1, + RATE_LIMITER_GLOBAL_STORE: 'memory', + ACCESS_TOKEN_TTL: '15m', REFRESH_TOKEN_TTL: '7d', REFRESH_TOKEN_COOKIE_SECURE: false, diff --git a/api/src/middleware/rate-limiter-global.ts b/api/src/middleware/rate-limiter-global.ts new file mode 100644 index 0000000000..56194870ae --- /dev/null +++ b/api/src/middleware/rate-limiter-global.ts @@ -0,0 +1,53 @@ +import { RequestHandler } from 'express'; +import ms from 'ms'; +import { RateLimiterMemcache, RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible'; +import env from '../env'; +import { HitRateLimitException } from '../exceptions/index'; +import logger from '../logger'; +import { createRateLimiter } from '../rate-limiter'; +import asyncHandler from '../utils/async-handler'; +import { validateEnv } from '../utils/validate-env'; + +const RATE_LIMITER_GLOBAL_KEY = 'global-rate-limit'; + +let checkRateLimit: RequestHandler = (_req, _res, next) => next(); +export let rateLimiterGlobal: RateLimiterRedis | RateLimiterMemcache | RateLimiterMemory; + +if (env.RATE_LIMITER_GLOBAL_ENABLED === true) { + validateEnv(['RATE_LIMITER_GLOBAL_STORE', 'RATE_LIMITER_GLOBAL_DURATION', 'RATE_LIMITER_GLOBAL_POINTS']); + validateConfiguration(); + + rateLimiterGlobal = createRateLimiter('RATE_LIMITER_GLOBAL'); + + checkRateLimit = asyncHandler(async (_req, res, next) => { + try { + await rateLimiterGlobal.consume(RATE_LIMITER_GLOBAL_KEY, 1); + } catch (rateLimiterRes: any) { + if (rateLimiterRes instanceof Error) throw rateLimiterRes; + + res.set('Retry-After', String(Math.round(rateLimiterRes.msBeforeNext / 1000))); + throw new HitRateLimitException(`Too many requests, retry after ${ms(rateLimiterRes.msBeforeNext)}.`, { + limit: +env.RATE_LIMITER_GLOBAL_POINTS, + reset: new Date(Date.now() + rateLimiterRes.msBeforeNext), + }); + } + + next(); + }); +} + +export default checkRateLimit; + +function validateConfiguration() { + if (env.RATE_LIMITER_ENABLED !== true) { + logger.error(`The IP based rate limiter needs to be enabled when using the global rate limiter.`); + process.exit(1); + } + const globalPointsPerSec = + Number(env.RATE_LIMITER_GLOBAL_POINTS) / Math.max(Number(env.RATE_LIMITER_GLOBAL_DURATION), 1); + const regularPointsPerSec = Number(env.RATE_LIMITER_POINTS) / Math.max(Number(env.RATE_LIMITER_DURATION), 1); + if (globalPointsPerSec <= regularPointsPerSec) { + logger.error(`The global rate limiter needs to allow more requests per second than the IP based rate limiter.`); + process.exit(1); + } +} diff --git a/api/src/middleware/rate-limiter.ts b/api/src/middleware/rate-limiter-ip.ts similarity index 85% rename from api/src/middleware/rate-limiter.ts rename to api/src/middleware/rate-limiter-ip.ts index 3d4c9793fd..71fa0d67ad 100644 --- a/api/src/middleware/rate-limiter.ts +++ b/api/src/middleware/rate-limiter-ip.ts @@ -8,13 +8,13 @@ import asyncHandler from '../utils/async-handler'; import { getIPFromReq } from '../utils/get-ip-from-req'; import { validateEnv } from '../utils/validate-env'; -let checkRateLimit: RequestHandler = (req, res, next) => next(); +let checkRateLimit: RequestHandler = (_req, _res, next) => next(); export let rateLimiter: RateLimiterRedis | RateLimiterMemcache | RateLimiterMemory; if (env.RATE_LIMITER_ENABLED === true) { validateEnv(['RATE_LIMITER_STORE', 'RATE_LIMITER_DURATION', 'RATE_LIMITER_POINTS']); - rateLimiter = createRateLimiter(); + rateLimiter = createRateLimiter('RATE_LIMITER'); checkRateLimit = asyncHandler(async (req, res, next) => { try { @@ -22,7 +22,7 @@ if (env.RATE_LIMITER_ENABLED === true) { } catch (rateLimiterRes: any) { if (rateLimiterRes instanceof Error) throw rateLimiterRes; - res.set('Retry-After', String(rateLimiterRes.msBeforeNext / 1000)); + res.set('Retry-After', String(Math.round(rateLimiterRes.msBeforeNext / 1000))); throw new HitRateLimitException(`Too many requests, retry after ${ms(rateLimiterRes.msBeforeNext)}.`, { limit: +env.RATE_LIMITER_POINTS, reset: new Date(Date.now() + rateLimiterRes.msBeforeNext), diff --git a/api/src/rate-limiter.ts b/api/src/rate-limiter.ts index 10cf7f1c7f..43e6ecbd5f 100644 --- a/api/src/rate-limiter.ts +++ b/api/src/rate-limiter.ts @@ -12,35 +12,47 @@ import { getConfigFromEnv } from './utils/get-config-from-env'; type IRateLimiterOptionsOverrides = Partial | Partial; -export function createRateLimiter(configOverrides?: IRateLimiterOptionsOverrides): RateLimiterAbstract { +export function createRateLimiter( + configPrefix = 'RATE_LIMITER', + configOverrides?: IRateLimiterOptionsOverrides +): RateLimiterAbstract { switch (env.RATE_LIMITER_STORE) { case 'redis': - return new RateLimiterRedis(getConfig('redis', configOverrides)); + return new RateLimiterRedis(getConfig('redis', configPrefix, configOverrides)); case 'memcache': - return new RateLimiterMemcache(getConfig('memcache', configOverrides)); + return new RateLimiterMemcache(getConfig('memcache', configPrefix, configOverrides)); case 'memory': default: - return new RateLimiterMemory(getConfig('memory', configOverrides)); + return new RateLimiterMemory(getConfig('memory', configPrefix, configOverrides)); } } -function getConfig(store: 'memory', overrides?: IRateLimiterOptionsOverrides): IRateLimiterOptions; -function getConfig(store: 'redis' | 'memcache', overrides?: IRateLimiterOptionsOverrides): IRateLimiterStoreOptions; +function getConfig( + store: 'memory', + configPrefix: string, + overrides?: IRateLimiterOptionsOverrides +): IRateLimiterOptions; +function getConfig( + store: 'redis' | 'memcache', + configPrefix: string, + overrides?: IRateLimiterOptionsOverrides +): IRateLimiterStoreOptions; function getConfig( store: 'memory' | 'redis' | 'memcache' = 'memory', + configPrefix = 'RATE_LIMITER', overrides?: IRateLimiterOptionsOverrides ): IRateLimiterOptions | IRateLimiterStoreOptions { - const config: any = getConfigFromEnv('RATE_LIMITER_', `RATE_LIMITER_${store}_`); + const config: any = getConfigFromEnv(`${configPrefix}_`, `${configPrefix}_${store}_`); if (store === 'redis') { const Redis = require('ioredis'); delete config.redis; - config.storeClient = new Redis(env.RATE_LIMITER_REDIS || getConfigFromEnv('RATE_LIMITER_REDIS_')); + config.storeClient = new Redis(env[`${configPrefix}_REDIS`] || getConfigFromEnv(`${configPrefix}_REDIS_`)); } if (store === 'memcache') { const Memcached = require('memcached'); - config.storeClient = new Memcached(env.RATE_LIMITER_MEMCACHE, getConfigFromEnv('RATE_LIMITER_MEMCACHE_')); + config.storeClient = new Memcached(env[`${configPrefix}_MEMCACHE`], getConfigFromEnv(`${configPrefix}_MEMCACHE_`)); } delete config.enabled; diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index 67a32f63dc..ef561c1462 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -22,7 +22,7 @@ import { ActivityService } from './activity'; import { SettingsService } from './settings'; import { TFAService } from './tfa'; -const loginAttemptsLimiter = createRateLimiter({ duration: 0 }); +const loginAttemptsLimiter = createRateLimiter('RATE_LIMITER', { duration: 0 }); export class AuthenticationService { knex: Knex; diff --git a/api/src/services/server.ts b/api/src/services/server.ts index 99a5cd5182..96e6988fbb 100644 --- a/api/src/services/server.ts +++ b/api/src/services/server.ts @@ -8,7 +8,7 @@ import { getCache } from '../cache'; import getDatabase, { hasDatabaseConnection } from '../database'; import env from '../env'; import logger from '../logger'; -import { rateLimiter } from '../middleware/rate-limiter'; +import { rateLimiter } from '../middleware/rate-limiter-ip'; import { getStorage } from '../storage'; import { AbstractServiceOptions } from '../types'; import { Accountability, SchemaOverview } from '@directus/shared/types'; @@ -17,6 +17,7 @@ import getMailer from '../mailer'; import { SettingsService } from './settings'; import { getOSInfo } from '../utils/get-os-info'; import { Readable } from 'node:stream'; +import { rateLimiterGlobal } from '../middleware/rate-limiter-global'; export class ServerService { knex: Knex; @@ -59,6 +60,14 @@ export class ServerService { } else { info.rateLimit = false; } + if (env.RATE_LIMITER_GLOBAL_ENABLED) { + info.rateLimitGlobal = { + points: env.RATE_LIMITER_GLOBAL_POINTS, + duration: env.RATE_LIMITER_GLOBAL_DURATION, + }; + } else { + info.rateLimitGlobal = false; + } info.flows = { execAllowedModules: env.FLOWS_EXEC_ALLOWED_MODULES ? toArray(env.FLOWS_EXEC_ALLOWED_MODULES) : [], @@ -117,7 +126,14 @@ export class ServerService { releaseId: version, serviceId: env.KEY, checks: merge( - ...(await Promise.all([testDatabase(), testCache(), testRateLimiter(), testStorage(), testEmail()])) + ...(await Promise.all([ + testDatabase(), + testCache(), + testRateLimiter(), + testRateLimiterGlobal(), + testStorage(), + testEmail(), + ])) ), }; @@ -286,6 +302,49 @@ export class ServerService { return checks; } + async function testRateLimiterGlobal(): Promise> { + if (env.RATE_LIMITER_GLOBAL_ENABLED !== true) { + return {}; + } + + const checks: Record = { + 'rateLimiterGlobal:responseTime': [ + { + status: 'ok', + componentType: 'ratelimiter', + observedValue: 0, + observedUnit: 'ms', + threshold: env.RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD + ? +env.RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD + : 150, + }, + ], + }; + + const startTime = performance.now(); + + try { + await rateLimiterGlobal.consume(`health-${checkID}`, 1); + await rateLimiterGlobal.delete(`health-${checkID}`); + } catch (err: any) { + checks['rateLimiterGlobal:responseTime'][0].status = 'error'; + checks['rateLimiterGlobal:responseTime'][0].output = err; + } finally { + const endTime = performance.now(); + checks['rateLimiterGlobal:responseTime'][0].observedValue = +(endTime - startTime).toFixed(3); + + if ( + checks['rateLimiterGlobal:responseTime'][0].observedValue > + checks['rateLimiterGlobal:responseTime'][0].threshold! && + checks['rateLimiterGlobal:responseTime'][0].status !== 'error' + ) { + checks['rateLimiterGlobal:responseTime'][0].status = 'warn'; + } + } + + return checks; + } + async function testStorage(): Promise> { const storage = await getStorage();