Rate Limiting em Server Actions e Route Handlers
O projeto inclui um utilitário de rate limiting in-memory por IP para proteger endpoints públicos de abuso. É usado nas server actions do site público (ex: formulário de contato) onde não há autenticação.
Utilitário
Arquivo: apps/web/lib/rate-limit.ts
interface RateLimitConfig {
windowMs: number; // janela de tempo em milissegundos
maxRequests: number; // máximo de requisições por IP dentro da janela
}
export function createRateLimiter(config: RateLimitConfig) {
const store = new Map<string, { count: number; resetAt: number }>();
return function check(ip: string): boolean {
const now = Date.now();
const entry = store.get(ip);
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + config.windowMs });
return true;
}
if (entry.count >= config.maxRequests) return false;
entry.count++;
return true;
};
}
createRateLimiter retorna uma função check(ip) que retorna true se a requisição está dentro do limite, ou false se deve ser bloqueada.
Configurações centralizadas
Arquivo: apps/web/config/rate_limit.config.ts
export const rateLimitConfig = {
contactForm: {
windowMs: 15 * 60 * 1000, // 15 minutos
maxRequests: 3, // 3 envios por IP na janela
},
} as const;
Adicione novas configurações aqui para manter todos os limites em um só lugar.
Uso em uma server action
'use server';
import { headers } from 'next/headers';
import { publicActionClient } from '@kit/next/safe-action';
import { rateLimitConfig } from '~/config/rate_limit.config';
import { createRateLimiter } from '~/lib/rate-limit';
// instanciar fora da action — o store persiste entre chamadas no mesmo processo
const checkRateLimit = createRateLimiter(rateLimitConfig.contactForm);
export const minhaAction = publicActionClient
.inputSchema(MeuSchema)
.action(async ({ parsedInput }) => {
const headersList = await headers();
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
if (!checkRateLimit(ip)) {
throw new Error('Muitas tentativas. Aguarde 15 minutos antes de tentar novamente.');
}
// ... lógica da action
});
O limiter deve ser instanciado fora da função da action para que o Map interno persista entre as chamadas. Se instanciado dentro da action, um novo Map é criado a cada invocação e o rate limiting não funciona.
Extração do IP
O header x-forwarded-for pode conter múltiplos IPs separados por vírgula quando há proxies ou CDNs intermediários. Sempre use o primeiro:
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
Limitações
| Limitação | Impacto |
|---|---|
| In-memory | O store é perdido ao reiniciar o processo. Em ambientes serverless cada instância tem seu próprio store. |
| Por processo | Em deployments com múltiplas instâncias (ex: ECS com múltiplas tasks), o limite não é compartilhado entre instâncias. |
| Sem persistência | Um restart da aplicação zera todos os contadores. |
Para rate limiting distribuído em produção com múltiplas instâncias, considerar Redis como backend de store.