feat: add rate limiting middleware for expensive API endpoints

Add an in-memory sliding window rate limiter as AdonisJS named
middleware. Applied to expensive endpoints to prevent abuse:
- POST /api/system/services/install: 5 req/min
- POST /api/benchmark/run{,/system,/ai}: 3 req/min
- POST /api/rag/upload: 10 req/min
- POST /api/ollama/models (pull): 5 req/min

Returns 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining
headers. Expired entries are cleaned up periodically.

https://claude.ai/code/session_01JFvpTYgm8GiE4vJ4cJKsFx
This commit is contained in:
Claude 2026-03-24 09:28:46 +00:00
parent b959196381
commit 912f1780ac
No known key found for this signature in database
3 changed files with 108 additions and 7 deletions

View File

@ -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<string, RateLimitEntry>()
// 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()
}
}

View File

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

View File

@ -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'])