mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-05 16:26:15 +02:00
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:
parent
b959196381
commit
912f1780ac
86
admin/app/middleware/rate_limit_middleware.ts
Normal file
86
admin/app/middleware/rate_limit_middleware.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,4 +45,6 @@ router.use([
|
||||||
* Named middleware collection must be explicitly assigned to
|
* Named middleware collection must be explicitly assigned to
|
||||||
* the routes or the routes group.
|
* the routes or the routes group.
|
||||||
*/
|
*/
|
||||||
export const middleware = router.named({})
|
export const middleware = router.named({
|
||||||
|
rateLimit: () => import('#middleware/rate_limit_middleware'),
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import SystemController from '#controllers/system_controller'
|
||||||
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
||||||
import ZimController from '#controllers/zim_controller'
|
import ZimController from '#controllers/zim_controller'
|
||||||
import router from '@adonisjs/core/services/router'
|
import router from '@adonisjs/core/services/router'
|
||||||
|
import { middleware } from '#start/kernel'
|
||||||
import transmit from '@adonisjs/transmit/services/main'
|
import transmit from '@adonisjs/transmit/services/main'
|
||||||
|
|
||||||
transmit.registerRoutes()
|
transmit.registerRoutes()
|
||||||
|
|
@ -104,7 +105,9 @@ router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.post('/chat', [OllamaController, 'chat'])
|
router.post('/chat', [OllamaController, 'chat'])
|
||||||
router.get('/models', [OllamaController, 'availableModels'])
|
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.delete('/models', [OllamaController, 'deleteModel'])
|
||||||
router.get('/installed-models', [OllamaController, 'installedModels'])
|
router.get('/installed-models', [OllamaController, 'installedModels'])
|
||||||
})
|
})
|
||||||
|
|
@ -126,7 +129,9 @@ router.get('/api/chat/suggestions', [ChatsController, 'suggestions'])
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.post('/upload', [RagController, 'upload'])
|
router
|
||||||
|
.post('/upload', [RagController, 'upload'])
|
||||||
|
.use(middleware.rateLimit({ limit: 10, window: 60 }))
|
||||||
router.get('/files', [RagController, 'getStoredFiles'])
|
router.get('/files', [RagController, 'getStoredFiles'])
|
||||||
router.delete('/files', [RagController, 'deleteFile'])
|
router.delete('/files', [RagController, 'deleteFile'])
|
||||||
router.get('/active-jobs', [RagController, 'getActiveJobs'])
|
router.get('/active-jobs', [RagController, 'getActiveJobs'])
|
||||||
|
|
@ -142,7 +147,9 @@ router
|
||||||
router.get('/internet-status', [SystemController, 'getInternetStatus'])
|
router.get('/internet-status', [SystemController, 'getInternetStatus'])
|
||||||
router.get('/services', [SystemController, 'getServices'])
|
router.get('/services', [SystemController, 'getServices'])
|
||||||
router.post('/services/affect', [SystemController, 'affectService'])
|
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/force-reinstall', [SystemController, 'forceReinstallService'])
|
||||||
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
||||||
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])
|
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])
|
||||||
|
|
@ -173,9 +180,15 @@ router
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.post('/run', [BenchmarkController, 'run'])
|
router
|
||||||
router.post('/run/system', [BenchmarkController, 'runSystem'])
|
.post('/run', [BenchmarkController, 'run'])
|
||||||
router.post('/run/ai', [BenchmarkController, 'runAI'])
|
.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', [BenchmarkController, 'results'])
|
||||||
router.get('/results/latest', [BenchmarkController, 'latest'])
|
router.get('/results/latest', [BenchmarkController, 'latest'])
|
||||||
router.get('/results/:id', [BenchmarkController, 'show'])
|
router.get('/results/:id', [BenchmarkController, 'show'])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user