Pular para o conteúdo principal

ADR-005: Rate Limiting In-Memory vs. Redis

CampoValor
Status✅ Accepted (com ressalva de escalabilidade)
Data2024-Q1
DecisoresTime de desenvolvimento
ImpactoMédio — afeta segurança do endpoint de login e resiliência a brute force

Contexto

O endpoint POST /api/auth/login aceita email e senha e, em caso de sucesso, emite um cookie de sessão JWT. Sem proteção, um atacante pode tentar centenas de combinações de senha por segundo contra uma conta específica (brute force) ou contra múltiplas contas (credential stuffing).

Os requisitos para a solução de rate limiting eram:

  1. Efetividade — deve realmente bloquear requisições em excesso, não apenas logar.
  2. Granularidade por IP — contas de diferentes usuários não devem se afetar mutuamente.
  3. Sliding window — mais justo que fixed window: não permite burst no início de cada janela.
  4. Custo zero de infraestrutura — a solução não deve exigir provisionar um serviço adicional.
  5. Sem dependências extras — o projeto já tem complexidade suficiente; adicionar uma biblioteca de rate limiting é overhead desnecessário se a implementação manual for simples.

O contexto de deploy é importante: a aplicação roda como um único processo Node.js na Hostinger (gerenciado pelo PM2 em modo fork, não cluster). Isso significa que há uma única instância do processo compartilhando memória.


Decisão

Adotamos uma implementação de sliding window rate limiting in-memory usando globalThis como storage compartilhado, implementada em lib/rate-limit.ts.

Configuração atual:

  • Janela: 15 minutos
  • Limite: 10 requisições por IP por janela
  • Penalidade: resposta 429 Too Many Requests com header Retry-After

Implementação

// lib/rate-limit.ts

type RateLimitEntry = {
timestamps: number[]; // timestamps das requisições na janela atual
};

// globalThis garante que o Map persiste entre hot-reloads em desenvolvimento
// e é compartilhado por todas as requisições no mesmo processo
declare global {
var __rateLimitStore: Map<string, RateLimitEntry> | undefined;
}

const store = (globalThis.__rateLimitStore ??= new Map());

const WINDOW_MS = 15 * 60 * 1000; // 15 minutos
const MAX_REQUESTS = 10;

export function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const entry = store.get(ip) ?? { timestamps: [] };

// Remove timestamps fora da janela (sliding window)
entry.timestamps = entry.timestamps.filter((t) => now - t < WINDOW_MS);

if (entry.timestamps.length >= MAX_REQUESTS) {
const oldest = entry.timestamps[0];
const retryAfter = Math.ceil((oldest + WINDOW_MS - now) / 1000);
return { allowed: false, retryAfter };
}

entry.timestamps.push(now);
store.set(ip, entry);
return { allowed: true };
}

Por que globalThis e não uma variável de módulo

Em Next.js com hot-reload (next dev), os módulos são reimportados frequentemente. Uma variável de módulo (const store = new Map()) seria resetada a cada reload, zerando os contadores. globalThis.__rateLimitStore persiste enquanto o processo Node.js estiver rodando, independentemente de reimportações de módulo.

Em produção (PM2 em modo fork), há apenas um processo e globalThis é o escopo correto.


Justificativa

Sliding window é mais seguro que fixed window

Com fixed window, um atacante pode enviar 10 requisições no segundo 59 de uma janela e 10 no segundo 1 da próxima janela — efetivamente enviando 20 requisições em 2 segundos sem ser bloqueado. O sliding window considera apenas os timestamps da janela móvel, eliminando esse burst.

Sem dependência externa

A implementação completa tem ~30 linhas de TypeScript sem dependências. Adicionar express-rate-limit, next-rate-limit ou @upstash/ratelimit seria trazer uma abstração para um problema que já está resolvido de forma simples e direta.

Adequado para a escala atual

O pico de tráfego projetado para um e-commerce de nicho artesanal é de dezenas de requisições simultâneas — muito abaixo do threshold onde um Map in-memory causaria problemas de memória ou contention. Para 10.000 IPs únicos com 10 timestamps cada, o overhead de memória é da ordem de megabytes.


Ressalva: Múltiplas Instâncias

⚠️ Atenção: A solução in-memory não funciona corretamente se o deploy evoluir para múltiplas instâncias do processo Node.js (ex.: PM2 em modo cluster, múltiplos workers, ou deploy em múltiplos servidores).

Em um cenário multi-instância, cada processo teria seu próprio globalThis.__rateLimitStore. Um atacante poderia enviar 10 requisições para a instância A e 10 para a instância B, totalizando 20 requisições sem ser bloqueado por nenhum dos dois stores.

Plano de migração para multi-instância:

Se o deploy evoluir, a migração recomendada é:

  1. Provisionar uma instância Redis (Upstash Redis para edge, ou Redis gerenciado na própria infraestrutura).
  2. Substituir lib/rate-limit.ts por uma implementação usando ioredis com o algoritmo de sliding window via script Lua (atomicidade garantida):
// Exemplo com ioredis — NÃO é o código atual, é o plano de migração
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

export async function checkRateLimit(ip: string) {
const key = `rate_limit:${ip}`;
const now = Date.now();
const windowStart = now - WINDOW_MS;

const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, '-inf', windowStart); // remove entradas antigas
pipeline.zadd(key, now, `${now}`); // adiciona requisição atual
pipeline.zcard(key); // conta requisições na janela
pipeline.expire(key, Math.ceil(WINDOW_MS / 1000)); // TTL automático

const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return { allowed: count <= MAX_REQUESTS };
}

Consequências

Positivas

  • Proteção imediata contra brute force — o endpoint /api/auth/login bloqueia após 10 tentativas em 15 minutos por IP.
  • Zero overhead de infraestrutura — sem Redis, sem Upstash, sem billing adicional.
  • Sliding window justo — sem burst no início de cada janela fixa.
  • Retry-After header — clientes legítimos sabem quando podem tentar novamente.
  • Implementação auditável — ~30 linhas de TypeScript, sem abstração escondida.

Negativas / Ressalvas

  • Não persiste após restart do processo — se o servidor reiniciar (deploy, crash), os contadores são zerados. Um atacante que monitorar o processo pode cronometrar tentativas em torno de restarts.
  • Não funciona em multi-instância — conforme descrito na seção de ressalva acima.
  • IPs atrás de proxy/load balancer — se o servidor estiver atrás de um proxy reverso, o IP real do cliente deve ser extraído do header X-Forwarded-For (com validação). Em produção na Hostinger, o servidor Node.js recebe o IP real diretamente.
  • Sem persistência de bloqueios — um usuário bloqueado que reinicia o router (novo IP) contorna o rate limit. Isso é uma limitação inerente ao rate limiting por IP e é mitigado pela lógica de lockout de conta (tentativas erradas incrementam contador na tabela User).

Alternativas consideradas

Upstash Redis (melhor para edge, descartado por custo)

Motivo: Upstash é a solução ideal para rate limiting em múltiplas instâncias ou no Edge Runtime, com billing por requisição (gratuito até 10.000 req/dia). Para a escala atual, o custo seria irrelevante, mas adiciona uma dependência externa e uma conta de serviço a gerenciar. Se o deploy migrar para edge ou cluster, Upstash é a primeira opção a avaliar.

next-rate-limit (descartado)

Motivo: Adiciona uma dependência para um problema que está resolvido em ~30 linhas de código próprio. A biblioteca também usa fixed window por padrão, que é menos seguro que sliding window.

Nginx rate limiting (não disponível)

Motivo: A Hostinger não oferece acesso à configuração do Nginx no plano de hospedagem compartilhada. Isso tornaria impossível implementar rate limiting no layer de proxy reverso, que seria a abordagem ideal em produção com acesso total ao servidor.

Bloqueio por conta (complementar, não substituto)

Nota: O rate limiting por IP é complementado por um lockout de conta na camada de aplicação — após N tentativas erradas consecutivas para o mesmo email, a conta é temporariamente bloqueada (campo loginAttempts e lockedUntil na tabela User). Esses dois mecanismos atuam em camadas diferentes e são complementares.


Referências