diff --git a/admin/app/middleware/rate_limit_middleware.ts b/admin/app/middleware/rate_limit_middleware.ts new file mode 100644 index 0000000..a5a0aa8 --- /dev/null +++ b/admin/app/middleware/rate_limit_middleware.ts @@ -0,0 +1,86 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * In-memory sliding window rate limiter. + * + * Tracks requests by IP + route pattern and enforces a configurable + * maximum number of requests per time window. Returns 429 Too Many + * Requests with standard rate-limit headers when the limit is exceeded. + */ + +interface RateLimitEntry { + timestamps: number[] +} + +const store = new Map() + +// Periodically clean up expired entries every 60 seconds +const CLEANUP_INTERVAL_MS = 60_000 + +setInterval(() => { + const now = Date.now() + for (const [key, entry] of store) { + // Remove entries whose newest timestamp is older than 5 minutes + // (the longest practical window we'd configure) + if (entry.timestamps.length === 0 || entry.timestamps[entry.timestamps.length - 1] < now - 5 * 60_000) { + store.delete(key) + } + } +}, CLEANUP_INTERVAL_MS).unref() + +export default class RateLimitMiddleware { + /** + * @param limit - Maximum number of requests allowed in the window + * @param windowSeconds - Length of the sliding window in seconds + */ + async handle( + { request, response }: HttpContext, + next: NextFn, + options: { limit: number; window: number } + ) { + const limit = options.limit ?? 10 + const windowSeconds = options.window ?? 60 + const windowMs = windowSeconds * 1000 + const now = Date.now() + + const ip = request.ip() + const routePattern = request.ctx?.route?.pattern ?? request.url() + const key = `${ip}:${routePattern}` + + let entry = store.get(key) + if (!entry) { + entry = { timestamps: [] } + store.set(key, entry) + } + + // Slide the window: remove timestamps outside the current window + const windowStart = now - windowMs + entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart) + + if (entry.timestamps.length >= limit) { + const oldestInWindow = entry.timestamps[0] + const retryAfterMs = oldestInWindow + windowMs - now + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000) + + response.header('Retry-After', String(retryAfterSeconds)) + response.header('X-RateLimit-Limit', String(limit)) + response.header('X-RateLimit-Remaining', '0') + response.header('X-RateLimit-Reset', String(Math.ceil((oldestInWindow + windowMs) / 1000))) + + return response.status(429).send({ + error: 'Too Many Requests', + message: `Rate limit exceeded. Try again in ${retryAfterSeconds} seconds.`, + }) + } + + // Record this request + entry.timestamps.push(now) + + // Set informational rate-limit headers on successful requests + response.header('X-RateLimit-Limit', String(limit)) + response.header('X-RateLimit-Remaining', String(limit - entry.timestamps.length)) + + return next() + } +} diff --git a/admin/start/kernel.ts b/admin/start/kernel.ts index 56e1afa..b231ebb 100644 --- a/admin/start/kernel.ts +++ b/admin/start/kernel.ts @@ -45,4 +45,6 @@ router.use([ * Named middleware collection must be explicitly assigned to * the routes or the routes group. */ -export const middleware = router.named({}) +export const middleware = router.named({ + rateLimit: () => import('#middleware/rate_limit_middleware'), +}) diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 631c528..016ded9 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -20,6 +20,7 @@ import SystemController from '#controllers/system_controller' import CollectionUpdatesController from '#controllers/collection_updates_controller' import ZimController from '#controllers/zim_controller' import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' import transmit from '@adonisjs/transmit/services/main' transmit.registerRoutes() @@ -104,7 +105,9 @@ router .group(() => { router.post('/chat', [OllamaController, 'chat']) router.get('/models', [OllamaController, 'availableModels']) - router.post('/models', [OllamaController, 'dispatchModelDownload']) + router + .post('/models', [OllamaController, 'dispatchModelDownload']) + .use(middleware.rateLimit({ limit: 5, window: 60 })) router.delete('/models', [OllamaController, 'deleteModel']) router.get('/installed-models', [OllamaController, 'installedModels']) }) @@ -126,7 +129,9 @@ router.get('/api/chat/suggestions', [ChatsController, 'suggestions']) router .group(() => { - router.post('/upload', [RagController, 'upload']) + router + .post('/upload', [RagController, 'upload']) + .use(middleware.rateLimit({ limit: 10, window: 60 })) router.get('/files', [RagController, 'getStoredFiles']) router.delete('/files', [RagController, 'deleteFile']) router.get('/active-jobs', [RagController, 'getActiveJobs']) @@ -142,7 +147,9 @@ router router.get('/internet-status', [SystemController, 'getInternetStatus']) router.get('/services', [SystemController, 'getServices']) router.post('/services/affect', [SystemController, 'affectService']) - router.post('/services/install', [SystemController, 'installService']) + router + .post('/services/install', [SystemController, 'installService']) + .use(middleware.rateLimit({ limit: 5, window: 60 })) router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']) router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']) router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']) @@ -173,9 +180,15 @@ router router .group(() => { - router.post('/run', [BenchmarkController, 'run']) - router.post('/run/system', [BenchmarkController, 'runSystem']) - router.post('/run/ai', [BenchmarkController, 'runAI']) + router + .post('/run', [BenchmarkController, 'run']) + .use(middleware.rateLimit({ limit: 3, window: 60 })) + router + .post('/run/system', [BenchmarkController, 'runSystem']) + .use(middleware.rateLimit({ limit: 3, window: 60 })) + router + .post('/run/ai', [BenchmarkController, 'runAI']) + .use(middleware.rateLimit({ limit: 3, window: 60 })) router.get('/results', [BenchmarkController, 'results']) router.get('/results/latest', [BenchmarkController, 'latest']) router.get('/results/:id', [BenchmarkController, 'show'])