Rate-Limits e Proteção Anti-Brute-Force
Consolidação de
RATE_LIMIT_SECURITY_REPORT.md— Última atualização: 2026-03-10 (v4)
Resumo
| Componente | Rate-Limit | Burst In-Memory | Lockout | Anti-Timing | Alertas Push | Cleanup | Observabilidade |
|---|---|---|---|---|---|---|---|
Sistema Principal (send-otp) | ✅ Por usuário + IP | — | ✅ 15 min fixo | ✅ 500-1000ms | ✅ Falha WhatsApp | ✅ maintenance-cron | — |
Sistema Principal (verify-otp) | ✅ 5 tentativas | — | ✅ 15 min fixo | ✅ 500-1000ms | ✅ Brute Force | ✅ maintenance-cron | — |
Portal (portal-escola) | ✅ Dual (IP + escola_id) | ✅ 150/5s por IP | ✅ Progressivo | ✅ 500-1000ms | ✅ ntfy (lockout + OTP + pico) | ✅ maintenance-cron | ✅ portal_rate_metrics |
1. Sistema Principal de Autenticação
1.1 send-otp — Envio de OTP
| Limite | Valor | Janela | Propósito |
|---|---|---|---|
| OTPs por usuário | 3 máximo | 15 minutos | Evitar spam de mensagens |
| OTPs por IP | 10 máximo | 1 hora | Evitar abuso massivo |
const MAX_OTPS_PER_USER = 3;
const OTP_WINDOW_MINUTES = 15;
const MAX_OTPS_PER_IP = 10;
const IP_WINDOW_HOURS = 1;Bloqueio: Retorna HTTP 429 com mensagem descritiva.
1.2 verify-otp — Verificação de OTP
| Limite | Valor | Janela |
|---|---|---|
| Tentativas falhas totais | 5 | 15 minutos |
| Tentativas por código OTP | 3 | Validade do OTP |
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_MINUTES = 15;- 3 erros no mesmo OTP → OTP invalidado permanentemente
- 5 falhas totais → Bloqueio por IP (15 min) + log
auth.bloqueio_ip
1.3 OTP — Parâmetros
| Parâmetro | Valor |
|---|---|
| Validade | 5 minutos |
| Hash | SHA-256 |
| Tabela | login_otps |
| Cleanup | maintenance-cron (diário, registros > 24h) |
1.4 JWT do Sistema
| Parâmetro | Valor |
|---|---|
| Algoritmo | HS256 |
| Secret | OLP_JWT_SECRET |
| Expiração | 8 horas |
| Cookie | olp_auth (HttpOnly, Secure, SameSite=None) |
2. Mural Olímpico (Aluno/Responsável)
2.1 Arquitetura de Defesa em Camadas (v4)
Request → [Burst In-Memory 150/5s] → [portal_check_guards RPC] → [Lockout por Identificador]
↓ (bloqueado) ↓ (bloqueado) ↓ (bloqueado)
429 (zero DB) 429 (1 query DB) 429 (dados da mesma query)Camadas em ordem de execução:
- Burst in-memory (150 req/5s por IP) — Bloqueia scripts triviais e loops acidentais sem custo de banco. Delegado ao Cloudflare Worker em produção; fallback
Map<string, number[]>em dev. - Rate limit por escola (200/min) — Controle principal contra ataques coordenados com múltiplos IPs.
- Rate limit por IP (400/5min) — Proteção secundária anti-bot, janela de 5 min suaviza picos de NAT.
- Lockout progressivo por identificador (3/6/10 falhas) — Defesa real contra brute force, bloqueios mais longos.
2.2 Rate-Limits — v4 (Conservador com Janelas Independentes)
Mudança v4 (2026-03-10): Valores mais conservadores + janelas independentes escola/IP.
- RPC
portal_check_guardsagora aceitap_window_minutes_ipseparado- Escola reduzida de 1400→200/min (margem 2.5x sobre pico real de 80/min)
- IP reduzido de 1800/1min→400/5min (janela mais longa suaviza NAT sem expor superfície)
- Lookup/Series reduzidos proporcionalmente (pré-login, menos permissivos)
- Lockouts mais longos (2/10/60 min vs 1/5/30 min)
- Alerta de pico: threshold reduzido de 80%→70%
Burst In-Memory
| Camada | Chave | Limite | Janela | Implementação | Custo DB |
|---|---|---|---|---|---|
| Burst | IP | 150 | 5 seg | Cloudflare Worker (prod) / Map (dev) | ZERO |
const BURST_LIMIT_IP = { max: 150, windowMs: 5_000 };Limitação aceita: Em dev/preview, o Map é por instância Deno isolate. Em produção, o Cloudflare Worker (processo único) garante contagem global.
Leitura Pública
| Ação | Chave | Limite | Janela |
|---|---|---|---|
lookup_escola | IP | 100 | 1 min |
lookup_escola | escola_id | 200 | 1 min |
get_series_escola | IP | 100 | 1 min |
check_slug_availability | IP | 60 | 1 min |
Login Método A (Matrícula + Nascimento — sem custo SMS)
| Ação | Chave | Limite | Janela |
|---|---|---|---|
login_aluno_a | Burst IP | 150 | 5 seg |
login_aluno_a | escola_id | 200 | 1 min |
login_aluno_a | IP | 400 | 5 min |
Justificativa escola=200/min: Escola pico real ~80 req/min. 200 dá 2.5x margem. Justificativa IP=400/5min: Janela de 5 minutos suaviza picos de NAT denso sem expor superfície ampla. Alerta de pico: ntfy dispara quando escola atinge 70% do rate limit (throttled 1x/15min). RPC:
portal_check_guardsusap_window_minutes=1para escola ep_window_minutes_ip=5para IP (janelas independentes).
Login Método B / OTP (custo de mensagens WhatsApp/SMS)
| Ação | Chave | Limite | Janela |
|---|---|---|---|
send_otp_aluno | escola_id | 100 | 15 min |
send_otp_aluno | IP | 50 | 15 min |
send_otp_responsavel | escola_id | 100 | 15 min |
send_otp_responsavel | IP | 50 | 15 min |
Cadastro / Vínculo
| Ação | Chave | Limite | Janela |
|---|---|---|---|
cadastro_responsavel | IP | 30 | 30 min |
auto_vincular_matricula | IP | 30 | 60 min |
// v4 — Constantes conservadoras (2026-03-10)
const BURST_LIMIT_IP = { max: 150, windowMs: 5_000 };
const RATE_LIMIT_LOOKUP_IP = { max: 100, windowMinutes: 1 };
const RATE_LIMIT_LOOKUP_ESCOLA = { max: 200, windowMinutes: 1 };
const RATE_LIMIT_SERIES_IP = { max: 100, windowMinutes: 1 };
const RATE_LIMIT_LOGIN_A_ESCOLA = { max: 200, windowMinutes: 1 };
const RATE_LIMIT_LOGIN_A_IP = { max: 400, windowMinutes: 5 }; // janela independente
const RATE_ALERT_THRESHOLD_PERCENT = 0.7;
const RATE_LIMIT_OTP_IP = { max: 50, windowMinutes: 15 };
const RATE_LIMIT_OTP_ESCOLA = { max: 100, windowMinutes: 15 };
const RATE_LIMIT_CHECK_SLUG = { max: 60, windowMinutes: 1 };
const RATE_LIMIT_CADASTRO = { max: 30, windowMinutes: 30 };
const RATE_LIMIT_VINCULO = { max: 30, windowMinutes: 60 };Mensagens 429 Contextuais
| Tipo | Mensagem |
|---|---|
| Burst por IP | "Muitos acessos da sua rede. Aguarde alguns segundos e tente novamente." |
| Rate limit por IP | "Muitos acessos da sua rede. Aguarde alguns minutos e tente novamente." |
| Rate limit por escola | "Muitos acessos simultâneos. Aguarde um momento e tente novamente." |
| Lockout por identificador | "Acesso temporariamente bloqueado. Tente novamente em X minuto(s)." |
2.3 Lockout Progressivo (por identificador APENAS)
| Falhas Acumuladas | Bloqueio | Janela de Contagem |
|---|---|---|
| 3 falhas | 2 minutos | 24 horas |
| 6 falhas | 10 minutos | 24 horas |
| 10 falhas | 60 minutos | 24 horas |
IMPORTANTE: Lockout opera exclusivamente por identificador (matrícula ou CPF), NÃO por IP. Em ambiente NAT escolar, todos os alunos compartilham o mesmo IP. Usar IP no lockout causava bloqueio de escola inteira quando poucos alunos erravam dados.
2.3.1 Throttle de Alertas Push (ntfy)
Alertas de lockout severo (10+ falhas) são throttled via Map em memória:
- Máximo 1 alerta por identificador (matrícula/CPF) a cada 5 minutos
- Evita flood de notificações sob carga
2.4 Anti-Timing Attack
Delay aleatório de 500-1000ms em respostas de erro para impedir timing attacks:
const randomDelay = () => new Promise(resolve =>
setTimeout(resolve, 500 + Math.random() * 500)
);2.5 OTP e JWT do Mural
| Parâmetro | OTP | JWT |
|---|---|---|
| Validade | 5 min | 2 horas (PORTAL_JWT_EXPIRY_HOURS) |
| Hash/Algo | SHA-256 | HS256 |
| Tabela | portal_otps | — |
| Cookie | — | olp_mural (HttpOnly, Secure, SameSite=None) |
| Secret | — | OLP_JWT_SECRET (unificado) |
2.6 checkRateLimit com keyField
checkRateLimit(supabase, keyValue, tipo, config, keyField)
// keyField: "ip" (default) | "escola_id" | "identificador"Handlers com dual check (escola_id + IP): se qualquer um falhar, bloqueia.
2.7 Tabela de Tentativas
- Colunas chave:
ip,escola_id,identificador,tipo_tentativa,sucesso - Rate limit: Conta TODAS as rows (sucesso + falha) por tipo + chave
- Lockout: Conta apenas
sucesso=falsepor identificador - Cleanup:
maintenance-cronremove registros > 7 dias
3. Observabilidade — Mural Rate Metrics
3.1 Tabela portal_rate_metrics
Agrega contagens de requests do mural por janela de 1 minuto, tipo de ação e escola:
| Coluna | Tipo | Descrição |
|---|---|---|
janela_inicio | timestamptz | Truncado ao minuto |
tipo_acao | text | login_aluno_a, lookup, send_otp_aluno, etc. |
escola_id | uuid | FK para escolas (nullable para lookups) |
contagem | int | Total de requests na janela |
contagem_bloqueada | int | Requests que receberam 429 |
- RLS: Ativo sem policies (acesso apenas via
service_role) - Cleanup:
maintenance-cronremove registros > 30 dias - Gravação: Fire-and-forget via RPC
portal_increment_metric(não bloqueia resposta) - UNIQUE index: Funcional com
COALESCE(escola_id, sentinel_uuid)para tratar NULL corretamente
3.2 RPC portal_increment_metric
portal_increment_metric(p_tipo text, p_escola_id uuid, p_blocked boolean DEFAULT false)Upsert atômico com ON CONFLICT usando COALESCE para tratar escola_id = NULL.
3.3 Cobertura de Métricas (v3)
| Action | Métrica | Status |
|---|---|---|
lookup_escola | ✅ | Existente |
login_aluno_a | ✅ | Existente |
send_otp_aluno | ✅ | Adicionado v3 |
verify_otp_aluno | ✅ | Adicionado v3 |
send_otp_responsavel | ✅ | Adicionado v3 |
verify_otp_responsavel | ✅ | Adicionado v3 |
cadastro_responsavel | ✅ | Adicionado v3 |
auto_vincular_matricula | ✅ | Adicionado v3 |
3.4 Dashboard Admin
Seção "Mural — Tráfego em Tempo Real" no dashboard administrativo:
- Gráfico de barras: Requests/minuto por ação (período configurável: 1h, 2h, 6h, 24h)
- Badges: Total de requests e % bloqueados
- Tabela: Top 10 escolas por volume (última hora) com indicador de % do rate limit
- Auto-refresh: A cada 60 segundos
3.5 Alertas Push de Pico
Quando uma escola atinge 70% do rate limit em qualquer janela:
⚠️ Portal Pico: Rate Limit Proximo
Escola {escola_id} atingiu 140/200 req/min (login_aluno_a)Throttled: 1 alerta por escola a cada 15 minutos (via Map em memória).
4. Comparativo Sistema vs Mural
Rate-Limits
| Métrica | Sistema | Mural |
|---|---|---|
| Burst por IP | N/A | 150/5s (in-memory) |
| Limite por usuário (OTP) | 3 OTPs/15min | N/A (lockout por identificador) |
| Limite por IP (OTP) | 10/hora | 50/15min |
| Limite por escola (OTP) | N/A | 100/15min |
| Limite por IP (lookup) | N/A | 100/min |
| Limite por escola (lookup) | N/A | 200/min |
| Limite por escola (login A) | N/A | 200/min |
| Limite por IP (login A) | N/A | 400/5min |
Lockouts
| Métrica | Sistema | Mural |
|---|---|---|
| Tipo | Fixo | Progressivo |
| Tentativas → bloqueio | 5 | 3/6/10 |
| Duração | 15 min | 2/10/60 min |
| Janela de contagem | 15 min | 24 horas |
JWTs
| Métrica | Sistema | Mural |
|---|---|---|
| Expiração | 8 horas | 2 horas |
| Cookie | olp_auth | olp_mural |
| Secret | OLP_JWT_SECRET | OLP_JWT_SECRET (unificado) |
5. Validação Frontend como Camada de Defesa
| Validação | Componente | Efeito |
|---|---|---|
| Botão desabilitado com campos vazios | MuralLoginAluno (Método A) | Impede request vazio |
max no input de data | MuralLoginAluno (Método A) | Impede data futura no HTML |
| Validação JS de data futura | handleLoginMetodoA | Rejeita antes do request |
| Validação de CPF inline | MuralLoginAluno (Método B) | Botão desabilitado com CPF inválido |
Nota: Validação frontend é defesa em profundidade, não substitui validação backend.
6. Problemas Identificados e Recomendações
| # | Severidade | Problema | Status | Recomendação |
|---|---|---|---|---|
| 1 | ✅ Resolvido | Anti-timing ausente no sistema principal | Implementado | Delay 500-1000ms |
| 2 | ✅ Resolvido | Portal limitava apenas por IP | Implementado (Fase A.1) | Dual check IP + escola_id |
| 3 | ✅ Resolvido | Console.logs vazando dados sensíveis | Implementado | Removidos |
| 4 | ✅ Resolvido | Alertas push ausentes no portal | Implementado (Fase B) | ntfy |
| 5 | ✅ Resolvido | Validação CPF ausente no backend | Implementado (Fase B) | isValidCPF() |
| 6 | ✅ Resolvido | Lockout usava OR com IP (tóxico para NAT) | Corrigido | Lockout apenas por identificador |
| 7 | ✅ Resolvido | ntfy flood sem throttle | Corrigido | Cooldown 5min |
| 8 | ✅ Resolvido | Rate limits altos permitiam DoS (81% CPU) | Corrigido (Fase A.2) | Reduzido + observabilidade |
| 9 | ✅ Resolvido | Rate limits muito baixos para NAT real | Corrigido (v3) | 1400/1800 + burst 150/5s |
| 10 | ✅ Resolvido | Métricas só em 2 de ~10 actions | Corrigido (v3) | Métricas em todas as actions |
| 11 | ✅ Resolvido | portal_increment_metric NULL bug | Corrigido (v3) | COALESCE no unique index |
| 12 | 🟢 Baixa | Janelas de tempo inconsistentes (15min vs 24h) | Aceito | Trade-off consciente |
| 13 | 🟢 Info | Sistema usa lockout fixo vs progressivo no portal | Aceito | Considerar migrar |
7. Monitoramento
Eventos logados em logs_transacoes:
| Evento | Ação de Log |
|---|---|
| Bloqueio por IP | auth.bloqueio_ip |
| Escola suspensa | auth.bloqueio_escola_status |
| Sem assinatura | auth.bloqueio_sem_assinatura |
| Perfil sem tela | auth.bloqueio_perfil_sem_tela |
Alertas Push (ntfy.sh)
| Evento | Prioridade | Endpoint |
|---|---|---|
| Brute force lockout (5+ falhas) | high | verify-otp |
| Falha no envio de OTP (WhatsApp/SMS) | high | send-otp |
| Reuso de token revogado | high | auth-helpers |
| Lockout severo mural (10+ falhas) | high | portal-escola |
| Rate limit OTP escola atingido | high | portal-escola |
| Pico de tráfego (70% rate limit) | high | portal-escola |
Observabilidade (Dashboard Admin)
| Métrica | Fonte | Período |
|---|---|---|
| Requests/minuto por ação | portal_rate_metrics | Configurável (1h–24h) |
| % requests bloqueados (429) | portal_rate_metrics | Configurável |
| Top escolas por volume | portal_rate_metrics | Última hora |
| % do rate limit por escola | Calculado (requests/200) | Última hora |
Referências
supabase/functions/send-otp/index.tssupabase/functions/verify-otp/index.tssupabase/functions/portal-escola/index.tssupabase/functions/maintenance-cron/index.tssupabase/functions/admin-dashboard/index.tssupabase/functions/_shared/auth-helpers.tssupabase/functions/_shared/supabase-client.ts— SingletoncreateSupabaseSystem()supabase/functions/_shared/jwt-portal.tssrc/hooks/usePortalMetrics.ts- Testes de Rate-Limit — Mural Olímpico