furt/src/rate_limiter.lua
michael 5c17c86fd4 feat(config): integrate rate limiting and CORS configuration from furt.conf
- Add RateLimiter:configure() function to accept config-based limits
- Integrate security section parameters (rate_limit_api_key_max, ip_max, window)
- Add CORS configuration from config file with environment fallback
- Replace hardcoded rate limiting defaults with configurable values
- Add test endpoint control via config.security.enable_test_endpoint
- Update startup logging to show actual configured rate limits
- Add configuration validation and detailed startup information

Rate limiting now uses values from [security] section instead of hardcoded
defaults. CORS origins prioritize config file over environment variables.

Related to DAW/furt#89
2025-08-29 20:01:47 +02:00

153 lines
4.3 KiB
Lua

-- src/rate_limiter.lua
-- Rate limiting system for API requests
-- Dragons@Work Digital Sovereignty Project
local RateLimiter = {
requests = {}, -- {api_key = {timestamps}, ip = {timestamps}}
cleanup_interval = 300, -- Cleanup every 5 minutes
last_cleanup = os.time(),
-- Default limits (configurable)
default_limits = {
api_key_max = 60, -- 60 requests per hour per API key
ip_max = 100, -- 100 requests per hour per IP
window = 3600 -- 1 hour window
}
}
-- Configure rate limits from config
function RateLimiter:configure(limits)
if limits then
if limits.api_key_max then
self.default_limits.api_key_max = limits.api_key_max
end
if limits.ip_max then
self.default_limits.ip_max = limits.ip_max
end
if limits.window then
self.default_limits.window = limits.window
end
print("Rate limiting configured:")
print(" API Key limit: " .. self.default_limits.api_key_max .. " req/hour")
print(" IP limit: " .. self.default_limits.ip_max .. " req/hour")
print(" Window: " .. self.default_limits.window .. " seconds")
end
end
-- Cleanup old requests from memory
function RateLimiter:cleanup_old_requests()
local now = os.time()
if now - self.last_cleanup < self.cleanup_interval then
return
end
local cutoff = now - self.default_limits.window
for key, timestamps in pairs(self.requests) do
local filtered = {}
for _, timestamp in ipairs(timestamps) do
if timestamp > cutoff then
table.insert(filtered, timestamp)
end
end
self.requests[key] = filtered
end
self.last_cleanup = now
end
-- Check if request is within rate limit
function RateLimiter:check_rate_limit(key, max_requests, window_seconds)
self:cleanup_old_requests()
local now = os.time()
local cutoff = now - (window_seconds or self.default_limits.window)
if not self.requests[key] then
self.requests[key] = {}
end
-- Count requests in time window
local count = 0
for _, timestamp in ipairs(self.requests[key]) do
if timestamp > cutoff then
count = count + 1
end
end
-- Check if limit exceeded
if count >= max_requests then
return false, count, max_requests - count
end
-- Record this request
table.insert(self.requests[key], now)
return true, count + 1, max_requests - (count + 1)
end
-- Check rate limits for API key and IP
function RateLimiter:check_api_and_ip_limits(api_key, client_ip)
-- Check API key rate limit
local api_key_allowed, api_count, api_remaining = self:check_rate_limit(
"api_key:" .. api_key,
self.default_limits.api_key_max,
self.default_limits.window
)
if not api_key_allowed then
return false, "API key rate limit exceeded", {
type = "api_key",
current = api_count,
limit = self.default_limits.api_key_max,
remaining = api_remaining
}
end
-- Check IP rate limit
local ip_allowed, ip_count, ip_remaining = self:check_rate_limit(
"ip:" .. client_ip,
self.default_limits.ip_max,
self.default_limits.window
)
if not ip_allowed then
return false, "IP rate limit exceeded", {
type = "ip",
current = ip_count,
limit = self.default_limits.ip_max,
remaining = ip_remaining
}
end
-- Both limits OK
return true, "OK", {
api_key = {
current = api_count,
limit = self.default_limits.api_key_max,
remaining = api_remaining
},
ip = {
current = ip_count,
limit = self.default_limits.ip_max,
remaining = ip_remaining
}
}
end
-- Get rate limit headers for HTTP response
function RateLimiter:get_rate_limit_headers(limit_info)
if not limit_info or not limit_info.api_key then
return {}
end
return {
["X-RateLimit-Remaining"] = tostring(limit_info.api_key.remaining or 0),
["X-RateLimit-Limit"] = tostring(self.default_limits.api_key_max),
["X-RateLimit-Window"] = tostring(self.default_limits.window)
}
end
return RateLimiter