mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01: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
|
||||
* 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 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'])
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user