mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
@@ -14,6 +14,13 @@ import { checkBudget } from "@/domain/costRules";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { checkRateLimit, RateLimitRule } from "./rateLimiter";
|
||||
|
||||
const DEFAULT_RATE_LIMITS: RateLimitRule[] = [
|
||||
{ limit: 1000, window: 86400 }, // 1000 per day
|
||||
{ limit: 5000, window: 604800 }, // 5000 per week
|
||||
{ limit: 20000, window: 2592000 } // 20000 per month
|
||||
];
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
@@ -34,10 +41,13 @@ export interface ApiKeyMetadata {
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
isActive?: boolean;
|
||||
isBanned?: boolean;
|
||||
expiresAt?: string | null;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
maxSessions?: number | null;
|
||||
rateLimits?: RateLimitRule[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,64 +116,7 @@ function isWithinSchedule(schedule: AccessSchedule): boolean {
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
// ── In-memory request counter for per-key rate limits (#452) ──
|
||||
|
||||
/** Sliding-window request timestamps per API key */
|
||||
const _requestTimestamps = new Map<string, number[]>();
|
||||
const REQUEST_COUNTER_MAX_KEYS = 5000;
|
||||
const REQUEST_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const REQUEST_MINUTE_MS = 60 * 1000;
|
||||
|
||||
/** Record a request and check per-key limits. Returns null if OK, or an error message. */
|
||||
function checkRequestCountLimits(
|
||||
apiKeyId: string,
|
||||
maxPerDay: number | null | undefined,
|
||||
maxPerMinute: number | null | undefined
|
||||
): string | null {
|
||||
if (!maxPerDay && !maxPerMinute) return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Get or create timestamp array for this key
|
||||
let timestamps = _requestTimestamps.get(apiKeyId);
|
||||
if (!timestamps) {
|
||||
timestamps = [];
|
||||
_requestTimestamps.set(apiKeyId, timestamps);
|
||||
// Prevent unbounded growth
|
||||
if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) {
|
||||
const firstKey = _requestTimestamps.keys().next().value;
|
||||
if (firstKey) _requestTimestamps.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune timestamps older than 24h
|
||||
const dayAgo = now - REQUEST_DAY_MS;
|
||||
while (timestamps.length > 0 && timestamps[0] < dayAgo) {
|
||||
timestamps.shift();
|
||||
}
|
||||
|
||||
// Check per-minute limit (before recording this request)
|
||||
if (maxPerMinute && maxPerMinute > 0) {
|
||||
const minuteAgo = now - REQUEST_MINUTE_MS;
|
||||
const recentCount = timestamps.filter((t) => t >= minuteAgo).length;
|
||||
if (recentCount >= maxPerMinute) {
|
||||
return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check per-day limit
|
||||
if (maxPerDay && maxPerDay > 0) {
|
||||
if (timestamps.length >= maxPerDay) {
|
||||
return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil(
|
||||
(timestamps[0] + REQUEST_DAY_MS - now) / 60000
|
||||
)} minutes.`;
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed — record this request
|
||||
timestamps.push(now);
|
||||
return null;
|
||||
}
|
||||
// Legacy in-memory request counter has been replaced by Redis-backed multi-window rate limiter
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
/** API key string (null if no key provided) */
|
||||
@@ -222,7 +175,7 @@ export async function enforceApiKeyPolicy(
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: is_active — hard block regardless of schedule ──
|
||||
// ── Check 1: is_active / is_banned ──
|
||||
if (apiKeyInfo.isActive === false) {
|
||||
return {
|
||||
apiKey,
|
||||
@@ -230,6 +183,25 @@ export async function enforceApiKeyPolicy(
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
|
||||
};
|
||||
}
|
||||
if (apiKeyInfo.isBanned === true) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is banned due to policy violations"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 1.5: expires_at ──
|
||||
if (apiKeyInfo.expiresAt) {
|
||||
const expiry = new Date(apiKeyInfo.expiresAt).getTime();
|
||||
if (Date.now() > expiry) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key has expired"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: access_schedule — time-based access window ──
|
||||
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
|
||||
@@ -286,18 +258,31 @@ export async function enforceApiKeyPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: Request-count limits (#452) ──
|
||||
if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) {
|
||||
const limitError = checkRequestCountLimits(
|
||||
apiKeyInfo.id,
|
||||
apiKeyInfo.maxRequestsPerDay,
|
||||
apiKeyInfo.maxRequestsPerMinute
|
||||
);
|
||||
if (limitError) {
|
||||
// ── Check 5: Generic Multi-Window Rate Limits ──
|
||||
if (apiKeyInfo.id) {
|
||||
const rulesToApply = (apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0)
|
||||
? [...apiKeyInfo.rateLimits]
|
||||
: [...DEFAULT_RATE_LIMITS];
|
||||
|
||||
// Combine with legacy limits if they exist and custom rate limits aren't set
|
||||
if (!apiKeyInfo.rateLimits || apiKeyInfo.rateLimits.length === 0) {
|
||||
if (apiKeyInfo.maxRequestsPerDay) {
|
||||
rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerDay, window: 86400 });
|
||||
}
|
||||
if (apiKeyInfo.maxRequestsPerMinute) {
|
||||
rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerMinute, window: 60 });
|
||||
}
|
||||
}
|
||||
|
||||
const rateLimitResult = await checkRateLimit(apiKeyInfo.id, rulesToApply);
|
||||
if (!rateLimitResult.allowed) {
|
||||
const failedWindowStr = rateLimitResult.failedWindow
|
||||
? ` (${rateLimitResult.failedWindow}s window)`
|
||||
: "";
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError),
|
||||
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, `Request limit exceeded${failedWindowStr}. Please try again later.`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
142
src/shared/utils/rateLimiter.ts
Normal file
142
src/shared/utils/rateLimiter.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
// Reuse existing REDIS_URL if set, or local redis via default docker-compose
|
||||
// Use REDIS_URL from env (Docker/Production) or fallback to local redis
|
||||
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.REDIS_URL) {
|
||||
console.warn('[REDIS] REDIS_URL is not set in production. Falling back to default.');
|
||||
}
|
||||
|
||||
|
||||
let redisClient: Redis | null = null;
|
||||
|
||||
export function getRedisClient() {
|
||||
if (!redisClient) {
|
||||
redisClient = new Redis(REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: false,
|
||||
retryStrategy(times) {
|
||||
return Math.min(times * 50, 2000); // Exponential backoff
|
||||
}
|
||||
});
|
||||
redisClient.on('error', (err) => console.error('[REDIS] Error:', err.message));
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
export interface RateLimitRule {
|
||||
limit: number;
|
||||
window: number; // in seconds
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
failedWindow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Lua script for multi-rule rate limiting using fixed window.
|
||||
* Returns {1, 0} if allowed, or {0, failedWindow} if rejected.
|
||||
*/
|
||||
const RATE_LIMIT_SCRIPT = `
|
||||
local key_prefix = KEYS[1]
|
||||
local current_time = tonumber(ARGV[1])
|
||||
|
||||
local rules = {}
|
||||
for i = 2, #ARGV, 2 do
|
||||
table.insert(rules, {
|
||||
limit = tonumber(ARGV[i]),
|
||||
window = tonumber(ARGV[i+1])
|
||||
})
|
||||
end
|
||||
|
||||
-- First pass: check if any limit is exceeded
|
||||
for i, rule in ipairs(rules) do
|
||||
local current_window = math.floor(current_time / rule.window)
|
||||
local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window
|
||||
|
||||
local count = tonumber(redis.call("GET", window_key) or "0")
|
||||
if count >= rule.limit then
|
||||
return { 0, rule.window } -- Reject, return which window failed
|
||||
end
|
||||
end
|
||||
|
||||
-- Second pass: increment all rules
|
||||
for i, rule in ipairs(rules) do
|
||||
local current_window = math.floor(current_time / rule.window)
|
||||
local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window
|
||||
|
||||
local count = redis.call("INCR", window_key)
|
||||
if count == 1 then
|
||||
-- TTL is twice the window size to ensure it covers the current window safely
|
||||
redis.call("EXPIRE", window_key, rule.window * 2)
|
||||
end
|
||||
end
|
||||
|
||||
return { 1, 0 } -- Accepted
|
||||
`;
|
||||
|
||||
const TEST_MEMORY_STORE = new Map<string, number>();
|
||||
let explicitTestMode = false;
|
||||
|
||||
export function setRateLimiterTestMode(enabled: boolean) {
|
||||
explicitTestMode = enabled;
|
||||
if (enabled) TEST_MEMORY_STORE.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks multi-window rate limits for an API key atomically via Redis.
|
||||
*/
|
||||
export async function checkRateLimit(
|
||||
keyId: string,
|
||||
rules: RateLimitRule[]
|
||||
): Promise<RateLimitResult> {
|
||||
if (!rules || rules.length === 0) return { allowed: true };
|
||||
|
||||
// ── In-memory mock for unit tests ──
|
||||
const isTestMode = explicitTestMode || process.env.NODE_ENV === "test" || process.env.DISABLE_SQLITE_AUTO_BACKUP === "true";
|
||||
|
||||
if (isTestMode) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const rule of rules) {
|
||||
const currentWindow = Math.floor(now / rule.window);
|
||||
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
|
||||
const count = TEST_MEMORY_STORE.get(windowKey) || 0;
|
||||
if (count >= rule.limit) {
|
||||
return { allowed: false, failedWindow: rule.window };
|
||||
}
|
||||
}
|
||||
for (const rule of rules) {
|
||||
const currentWindow = Math.floor(now / rule.window);
|
||||
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
|
||||
TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1);
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
const args: (string | number)[] = [Math.floor(Date.now() / 1000)];
|
||||
|
||||
for (const rule of rules) {
|
||||
args.push(rule.limit, rule.window);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redis.eval(
|
||||
RATE_LIMIT_SCRIPT,
|
||||
1,
|
||||
`rl:api_key:${keyId}`,
|
||||
...args
|
||||
) as [number, number];
|
||||
|
||||
if (result[0] === 0) {
|
||||
return { allowed: false, failedWindow: result[1] };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
// Fail-open strategy if Redis goes down to prevent complete API outage
|
||||
console.error("[RATE_LIMITER] Redis eval failed, bypassing rate limit:", error);
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
@@ -1465,8 +1465,11 @@ export const updateKeyPermissionsSchema = z
|
||||
noLog: z.boolean().optional(),
|
||||
autoResolve: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isBanned: z.boolean().optional(),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
maxSessions: z.number().int().min(0).max(10000).optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
rateLimits: z.union([z.array(z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })).max(50), z.null()]).optional(),
|
||||
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -1477,8 +1480,11 @@ export const updateKeyPermissionsSchema = z
|
||||
value.noLog === undefined &&
|
||||
value.autoResolve === undefined &&
|
||||
value.isActive === undefined &&
|
||||
value.isBanned === undefined &&
|
||||
value.expiresAt === undefined &&
|
||||
value.maxSessions === undefined &&
|
||||
value.accessSchedule === undefined &&
|
||||
value.rateLimits === undefined
|
||||
value.scopes === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
|
||||
Reference in New Issue
Block a user