202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { logSecurityEvent } from './security-events'
|
|
|
|
interface RateLimitEntry {
|
|
count: number
|
|
resetAt: number
|
|
}
|
|
|
|
interface RateLimiterOptions {
|
|
windowMs: number
|
|
maxRequests: number
|
|
message?: string
|
|
/** If true, MC_DISABLE_RATE_LIMIT will not bypass this limiter */
|
|
critical?: boolean
|
|
/** Max entries in the backing map before evicting oldest (default: 10_000) */
|
|
maxEntries?: number
|
|
}
|
|
|
|
const DEFAULT_MAX_ENTRIES = 10_000
|
|
|
|
/** Evict the entry with the earliest resetAt when at capacity */
|
|
function evictOldest(store: Map<string, RateLimitEntry>) {
|
|
let oldestKey: string | null = null
|
|
let oldestReset = Infinity
|
|
for (const [key, entry] of store) {
|
|
if (entry.resetAt < oldestReset) {
|
|
oldestReset = entry.resetAt
|
|
oldestKey = key
|
|
}
|
|
}
|
|
if (oldestKey) store.delete(oldestKey)
|
|
}
|
|
|
|
// Trusted proxy IPs (comma-separated). Only parse XFF when behind known proxies.
|
|
const TRUSTED_PROXIES = new Set(
|
|
(process.env.MC_TRUSTED_PROXIES || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
)
|
|
|
|
/**
|
|
* Extract client IP from request headers.
|
|
* When MC_TRUSTED_PROXIES is set, takes the rightmost untrusted IP from x-forwarded-for.
|
|
* Without trusted proxies, falls back to x-real-ip or 'unknown'.
|
|
*/
|
|
export function extractClientIp(request: Request): string {
|
|
const xff = request.headers.get('x-forwarded-for')
|
|
|
|
if (xff && TRUSTED_PROXIES.size > 0) {
|
|
// Walk the chain from right to left, skip trusted proxies, return first untrusted
|
|
const ips = xff.split(',').map(s => s.trim())
|
|
for (let i = ips.length - 1; i >= 0; i--) {
|
|
if (!TRUSTED_PROXIES.has(ips[i])) return ips[i]
|
|
}
|
|
}
|
|
|
|
// Fallback: x-real-ip (set by nginx/caddy) or 'unknown'
|
|
return request.headers.get('x-real-ip')?.trim() || 'unknown'
|
|
}
|
|
|
|
export function createRateLimiter(options: RateLimiterOptions) {
|
|
const store = new Map<string, RateLimitEntry>()
|
|
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
|
|
// Periodic cleanup every 60s
|
|
const cleanupInterval = setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [key, entry] of store) {
|
|
if (now > entry.resetAt) store.delete(key)
|
|
}
|
|
}, 60_000)
|
|
// Don't prevent process exit
|
|
if (cleanupInterval.unref) cleanupInterval.unref()
|
|
|
|
return function checkRateLimit(request: Request): NextResponse | null {
|
|
// Allow disabling non-critical rate limiting for E2E tests
|
|
// In CI, standalone server runs with NODE_ENV=production but needs rate limit bypass
|
|
if (process.env.MC_DISABLE_RATE_LIMIT === '1' && !options.critical && (process.env.NODE_ENV !== 'production' || process.env.MISSION_CONTROL_TEST_MODE === '1')) return null
|
|
const ip = extractClientIp(request)
|
|
const now = Date.now()
|
|
const entry = store.get(ip)
|
|
|
|
if (!entry || now > entry.resetAt) {
|
|
if (!entry && store.size >= maxEntries) evictOldest(store)
|
|
store.set(ip, { count: 1, resetAt: now + options.windowMs })
|
|
return null
|
|
}
|
|
|
|
entry.count++
|
|
if (entry.count > options.maxRequests) {
|
|
try { logSecurityEvent({ event_type: 'rate_limit_hit', severity: 'warning', source: 'rate-limiter', detail: JSON.stringify({ ip }), ip_address: ip, workspace_id: 1, tenant_id: 1 }) } catch {}
|
|
return NextResponse.json(
|
|
{ error: options.message || 'Too many requests. Please try again later.' },
|
|
{ status: 429 }
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const loginLimiter = createRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 5,
|
|
message: 'Too many login attempts. Try again in a minute.',
|
|
critical: true,
|
|
})
|
|
|
|
export const mutationLimiter = createRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 60,
|
|
})
|
|
|
|
export const readLimiter = createRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 120,
|
|
})
|
|
|
|
export const heavyLimiter = createRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 10,
|
|
message: 'Too many requests for this resource. Please try again later.',
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-agent rate limiter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Rate limit by agent identity (x-agent-name header) instead of IP.
|
|
* Useful for agent-facing endpoints where multiple agents share an IP
|
|
* (e.g. all running on the same server) but each should have its own quota.
|
|
*
|
|
* Falls back to IP-based limiting if no agent name is provided.
|
|
*/
|
|
export function createAgentRateLimiter(options: RateLimiterOptions) {
|
|
const store = new Map<string, RateLimitEntry>()
|
|
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
|
|
const cleanupInterval = setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [key, entry] of store) {
|
|
if (now > entry.resetAt) store.delete(key)
|
|
}
|
|
}, 60_000)
|
|
if (cleanupInterval.unref) cleanupInterval.unref()
|
|
|
|
return function checkAgentRateLimit(request: Request): NextResponse | null {
|
|
if (process.env.MC_DISABLE_RATE_LIMIT === '1' && !options.critical && (process.env.NODE_ENV !== 'production' || process.env.MISSION_CONTROL_TEST_MODE === '1')) return null
|
|
|
|
const agentName = (request.headers.get('x-agent-name') || '').trim()
|
|
const key = agentName || `ip:${extractClientIp(request)}`
|
|
const now = Date.now()
|
|
const entry = store.get(key)
|
|
|
|
if (!entry || now > entry.resetAt) {
|
|
if (!entry && store.size >= maxEntries) evictOldest(store)
|
|
store.set(key, { count: 1, resetAt: now + options.windowMs })
|
|
return null
|
|
}
|
|
|
|
entry.count++
|
|
if (entry.count > options.maxRequests) {
|
|
try { logSecurityEvent({ event_type: 'rate_limit_hit', severity: 'warning', source: 'rate-limiter', agent_name: agentName || undefined, detail: JSON.stringify({ ip: key }), ip_address: typeof key === 'string' ? key : 'unknown', workspace_id: 1, tenant_id: 1 }) } catch {}
|
|
const who = agentName ? `Agent "${agentName}"` : 'Client'
|
|
return NextResponse.json(
|
|
{ error: options.message || `${who} has exceeded the rate limit. Please try again later.` },
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Retry-After': String(Math.ceil((entry.resetAt - now) / 1000)),
|
|
'X-RateLimit-Limit': String(options.maxRequests),
|
|
'X-RateLimit-Remaining': '0',
|
|
'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)),
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Per-agent heartbeat/status updates: 30/min per agent */
|
|
export const agentHeartbeatLimiter = createAgentRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 30,
|
|
message: 'Agent heartbeat rate limit exceeded.',
|
|
})
|
|
|
|
/** Per-agent task polling: 20/min per agent */
|
|
export const agentTaskLimiter = createAgentRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 20,
|
|
message: 'Agent task polling rate limit exceeded.',
|
|
})
|
|
|
|
/** Self-registration: 5/min per IP (prevent spam registrations) */
|
|
export const selfRegisterLimiter = createRateLimiter({
|
|
windowMs: 60_000,
|
|
maxRequests: 5,
|
|
message: 'Too many registration attempts. Please try again later.',
|
|
})
|