Relatório Técnico de Segurança — Plataforma OLP
Versão: 1.0.0
Data: 2026-02-28 14:30 BRT
Auditor: Lovable AI (análise automatizada de código-fonte)
Escopo: Todas as camadas — Frontend, Worker Cloudflare, API (Edge Functions), Supabase, Autenticação, Portal Público, Admin, Banco de Dados, Infraestrutura, Dependências
Classificação: Documento interno — uso restrito à equipe de engenharia e segurança
Sumário Executivo
A plataforma OLP apresenta um nível de maturidade de segurança acima da média para aplicações de seu porte, com destaque para:
- ✅ Autenticação JWT customizada com cookies HttpOnly (sem fallback para Authorization header)
- ✅ Separação completa de contextos admin/portal via claims JWT distintas (secret unificado
OLP_JWT_SECRETpor exigência do PostgREST/RLS) - ✅ RLS ativo em 100% das tabelas (57 tabelas à data da auditoria — verificar contagem atual)
- ✅ Rate limiting por IP e por usuário em endpoints críticos
- ✅ Lockout progressivo no portal público
- ✅ Logs estruturados com geolocalização via Cloudflare
- ✅ Monitoramento CRON com escalação automática (push ntfy + alertas in-app)
Gaps críticos identificados:
- ✅ Headers de segurança HTTP completos (meta tags no frontend + 6 headers HTTP no Cloudflare Worker, incluindo HSTS)
- ✅ Revogação server-side de tokens JWT (jti + token_blacklist) — Implementado em 2026-03-01
- ✅ Alertas push via ntfy.sh para anomalias críticas — Implementado em 2026-03-01
- ✅ Auditoria de dependências: Dependabot configurado, xlsx (CVE-2023-30533) migrado para ExcelJS — 2026-03-01
Índice
- Autenticação JWT Customizada
- Autorização e RBAC
- Isolamento Multi-Tenant
- IDOR e Enumeração de UUID
- Rate Limiting
- Lockout Progressivo
- Proteção contra Brute Force
- CORS
- Headers de Segurança HTTP
- Proteção contra Replay de Token
- Logs Estruturados
- Monitoramento e Alertas
- Segurança de Dependências (Supply Chain)
- Proteção contra Scraping e DoS
- Segurança de Secrets e Variáveis de Ambiente
1. Autenticação JWT Customizada
Status: ✅ Implementado
O que está implementado
A plataforma utiliza autenticação JWT customizada (não usa Supabase Auth nativo) com separação completa entre sistema administrativo e portal público.
Dois contextos de JWT:
| Aspecto | Sistema Admin | Portal Aluno/Responsável |
|---|---|---|
| Secret | OLP_JWT_SECRET | OLP_JWT_SECRET (unificado para RLS) |
| Cookie | olp_auth | olp_mural |
| Expiração | 8 horas | 2 horas |
| Claims diferenciantes | principal_role, escola_id | portal_type, escopo: portal_readonly |
| Algoritmo | HS256 (jose) | HS256 (jose) |
| Flags do Cookie | HttpOnly; Secure; SameSite=None; Path=/ | HttpOnly; Secure; SameSite=None; Path=/ |
Medidas implementadas:
Cookie-only authentication — Fallback para
Authorizationheader foi removido explicitamente emauth-helpers.ts:46-48esupabase-client.ts:40-42. Comentários no código documentam a decisão:typescript// SEGURANÇA: Fallback Authorization header REMOVIDO // Autenticação exclusivamente via cookie HttpOnly para prevenir XSSCross-context blocking — Tokens de portal não são aceitos pelo sistema admin. O helper
verifyPortalToken()valida presença deportal_typee rejeita tokens comprincipal_role(jwt-portal.ts:98-101):typescriptif ((payload as any).principal_role && !typedPayload.portal_type) { throw new Error('Token de sistema não pode acessar portal'); }Logout multi-domínio — Remove cookies em ambos os domínios (com e sem
Domain=.olp.digital) usandoheaders.append()(não join com vírgula). Evidência:logout/index.ts:70-73.OTP com hash SHA-256 — OTP nunca é armazenado em texto plano. Hash gerado via
crypto.subtle.digest('SHA-256', ...)emsend-otp/index.ts:387-391.OTP com expiração curta — 5 minutos (
send-otp/index.ts:394).Mensagens genéricas — Não revela se usuário existe ou está inativo (
send-otp/index.ts:140-148,verify-otp/index.ts:96):typescriptconst genericAuthError = 'Não foi possível processar sua solicitação. Verifique os dados ou contate o suporte.';
O que NÃO está implementado
- Rotação automática de secrets —
OLP_JWT_SECRETnão possui política de rotação. Uma troca exige invalidação de todas as sessões ativas.
Riscos
| Risco | Cenário de Ataque | Impacto |
|---|---|---|
| Token válido após logout | Usuário faz logout mas atacante capturou cookie via rede (não XSS, pois é HttpOnly). Token permanece válido por até 8h. | Médio-Alto |
| Timing side-channel | Atacante mede tempo de resposta de send-otp para inferir se CPF/INEP existe no sistema. Delay entre lookup primário (1 query) vs secundário (2 queries) é mensurável. | Baixo-Médio |
| Secret comprometido | Se OLP_JWT_SECRET vazar, TODOS os tokens (admin + portal) podem ser forjados. | Crítico |
Criticidade: Médio-Alto
A ausência de revogação server-side é o gap mais significativo. Mitigação parcial: expiração relativamente curta (8h/2h).
Como testar manualmente (staging)
Teste 1: Token válido após logout
# 1. Fazer login e capturar cookie
curl -c cookies.txt -X POST https://[SUPABASE_URL]/functions/v1/verify-otp \
-H "Content-Type: application/json" \
-H "Origin: https://olp.digital" \
-d '{"codigo":"CPF_VALIDO","otp":"CODIGO_OTP"}'
# 2. Verificar sessão ativa
curl -b cookies.txt https://[SUPABASE_URL]/functions/v1/me \
-H "Origin: https://olp.digital"
# Esperado: success: true
# 3. Fazer logout
curl -b cookies.txt -X POST https://[SUPABASE_URL]/functions/v1/logout \
-H "Origin: https://olp.digital"
# 4. Reusar o cookie antigo manualmente (simular token não revogado)
curl -b cookies.txt https://[SUPABASE_URL]/functions/v1/me \
-H "Origin: https://olp.digital"
# ATUAL: success: true (token ainda válido — GAP!)
# IDEAL: success: false (token na blacklist)Teste 2: Cross-context blocking
# Capturar cookie olp_mural (login de aluno)
# Tentar usar em endpoint admin (ex: /me para sistema)
curl -b "olp_mural=TOKEN_MURAL" https://[SUPABASE_URL]/functions/v1/me \
-H "Origin: https://olp.digital"
# Esperado: success: false (me.ts procura olp_auth, não olp_mural)Teste 3: Timing side-channel
# Medir tempo de resposta para CPF existente vs inexistente
for i in {1..50}; do
time curl -s -X POST https://[SUPABASE_URL]/functions/v1/send-otp \
-H "Content-Type: application/json" \
-d '{"codigo":"12345678901"}' # CPF existente
done
for i in {1..50}; do
time curl -s -X POST https://[SUPABASE_URL]/functions/v1/send-otp \
-H "Content-Type: application/json" \
-d '{"codigo":"99999999999"}' # CPF inexistente
done
# Comparar distribuição de temposComo testar via Playwright
import { test, expect } from '@playwright/test';
test('token de sistema não funciona em portal', async ({ page }) => {
// Login no sistema admin
await page.goto('/');
await page.fill('[data-testid="codigo-input"]', 'CPF_ADMIN');
await page.click('[data-testid="btn-enviar-codigo"]');
// ... completar fluxo OTP
// Capturar cookie olp_auth
const cookies = await page.context().cookies();
const olpAuth = cookies.find(c => c.name === 'olp_auth');
expect(olpAuth).toBeDefined();
// Tentar acessar portal com cookie de sistema
await page.goto('/escola/slug-teste');
// Portal não deve reconhecer sessão de sistema
expect(page.locator('[data-testid="portal-login-form"]')).toBeVisible();
});
test('logout remove cookie', async ({ page }) => {
// Login
// ... fluxo de login
// Logout
await page.click('[data-testid="btn-logout"]');
// Verificar cookie removido
const cookies = await page.context().cookies();
const olpAuth = cookies.find(c => c.name === 'olp_auth');
expect(olpAuth).toBeUndefined();
});Como validar via logs
-- Verificar logins bem-sucedidos
SELECT
criado_em, nome_usuario, papel_principal, ip, cidade, estado,
detalhes->>'tipo_codigo' as tipo_codigo,
detalhes->>'principal_role' as role_jwt
FROM logs_transacoes
WHERE acao = 'login.success'
ORDER BY criado_em DESC
LIMIT 20;
-- Verificar logouts
SELECT criado_em, nome_usuario, ip, detalhes
FROM logs_transacoes
WHERE acao = 'login.logout'
ORDER BY criado_em DESC
LIMIT 20;
-- Verificar bloqueios de escola/assinatura no login
SELECT criado_em, nome_usuario, acao, detalhes
FROM logs_transacoes
WHERE acao LIKE 'auth.bloqueio%'
ORDER BY criado_em DESC
LIMIT 20;Monitoramento e alertas
- ✅ Logins bem-sucedidos logados em
logs_transacoescom açãologin.success - ✅ Logouts logados com ação
login.logout - ✅ Bloqueios por status de escola logados (
auth.bloqueio_escola_status) - ✅ Bloqueios por perfil sem tela logados (
auth.bloqueio_perfil_sem_tela) - ❌ Não há alerta automático para login de IP suspeito ou geolocalização atípica
- ❌ Não há dashboard de sessões ativas ou histórico de logins por usuário
Reação automática
| Cenário | Reação | Evidência |
|---|---|---|
| Escola suspensa/encerrada | Bloqueia envio de OTP + login | send-otp/index.ts:251-287 |
| Trial expirado | Bloqueia envio de OTP | send-otp/index.ts:291-324 |
| Perfil sem tela implementada | Bloqueia envio de OTP | send-otp/index.ts:197-226 |
| Usuário inativo | Mensagem genérica (não revela inatividade) | send-otp/index.ts:151-159 |
Evidência técnica
| Arquivo | Função/Trecho | Descrição |
|---|---|---|
supabase/functions/_shared/jwt.ts:33-56 | signAuthToken() | Assina JWT admin com HS256, 8h, role: authenticated |
supabase/functions/_shared/jwt-portal.ts:45-72 | signPortalToken() | Assina JWT portal com 2h (PORTAL_JWT_EXPIRY_HOURS), portal_type, escopo: portal_readonly |
supabase/functions/_shared/auth-helpers.ts:26-51 | extractToken() | Extrai token APENAS do cookie olp_auth (sem fallback header) |
supabase/functions/_shared/auth-helpers.ts:57-85 | extractAuthenticatedUser() | Valida e decodifica JWT do sistema |
supabase/functions/_shared/jwt-portal.ts:79-110 | verifyPortalToken() | Valida JWT do portal + bloqueia cross-context |
supabase/functions/verify-otp/index.ts:491-501 | Cookie setup | HttpOnly; Secure; SameSite=None; Path=/; Max-Age=28800 (8h) |
supabase/functions/logout/index.ts:54-74 | Logout | Remove cookie em ambos domínios (com/sem Domain) |
supabase/functions/_shared/supabase-client.ts:32-60 | createSupabaseClient() | Extrai token do cookie olp_auth para RLS |
supabase/functions/_shared/supabase-client.ts:137-164 | createSupabasePortalAuth() | Extrai token do cookie olp_mural para RLS |
2. Autorização e RBAC
Status: ✅ Implementado
O que está implementado
Sistema RBAC (Role-Based Access Control) com 7+ papéis e verificação em todas as Edge Functions autenticadas.
Papéis do sistema:
administrador > especialista > escola > escola_trial > coordenador > diretor > pedagogico > professor > marketingMecanismo de verificação:
requireRole(req, roles, recurso)— Middleware genérico que:- Extrai usuário do cookie JWT
- Verifica se possui pelo menos um dos papéis requeridos
- Em caso de falha: loga incidente de segurança via
logAccessDenied()e retorna 403
requireAdmin(req)— Atalho pararequireRole(req, 'administrador', 'admin-area')logAccessDenied()— Registra automaticamente emlogs_transacoes:json{ "acao": "permissao.acesso_negado", "detalhes": { "tipo": "security", "recurso": "admin-escolas", "papel_usuario": "coordenador", "papel_requerido": "administrador", "todos_papeis": ["coordenador"], "url": "...", "method": "POST" } }Permissões dinâmicas por papel —
permissions.tsdefine áreas acessíveis por papel (ex: coordenador pode vercalendario,olimpiadas_coord, mas nãoalunosouconfiguracoes). Permissões obrigatórias (ex:painel_controlepara coordenador) nunca podem ser removidas.Normalização lowercase — Todos os papéis são normalizados para lowercase em
extractAuthenticatedUser()everify-otp, prevenindo bypass por case-sensitivity.
O que NÃO está implementado
Auditoria de mudanças de papel — Não há log específico quando um gestor altera permissões de um coordenador/diretor (embora operações de UPDATE genéricas são logadas).
Segregação temporal — Não há conceito de papéis com expiração automática (ex: "coordenador temporário por 30 dias").
Riscos
| Risco | Cenário | Impacto |
|---|---|---|
| Escalação horizontal | Gestor de escola A cria coordenador com acesso a dados de escola B | Mitigado por RLS (escola_id no JWT) |
| Papel legado | Usuário com papel professor no banco mas sem tela implementada | Mitigado por bloqueio em send-otp (PAPEIS_COM_TELA) |
Criticidade: Baixo
O RBAC está bem implementado. RLS fornece defesa em profundidade.
Como testar manualmente (staging)
# Tentar acessar endpoint admin com cookie de coordenador
curl -b "olp_auth=TOKEN_COORDENADOR" \
-X POST https://[SUPABASE_URL]/functions/v1/admin-escolas \
-H "Content-Type: application/json" \
-H "Origin: https://olp.digital" \
-d '{"action":"list"}'
# Esperado: 403 "Acesso negado"
# Log criado: permissao.acesso_negadoComo testar via Playwright
test('coordenador não acessa menu admin', async ({ page }) => {
// Login como coordenador
// ... fluxo de login
// Verificar que não há link para admin
await expect(page.locator('[data-testid="admin-menu"]')).not.toBeVisible();
// Tentar navegar diretamente
await page.goto('/admin');
// Deve redirecionar para dashboard do coordenador
await expect(page).toHaveURL(/coordenacao/);
});Como validar via logs
-- Incidentes de acesso negado
SELECT
criado_em, nome_usuario, ip, cidade,
detalhes->>'recurso' as recurso,
detalhes->>'papel_usuario' as papel_usuario,
detalhes->>'papel_requerido' as papel_requerido
FROM logs_transacoes
WHERE acao = 'permissao.acesso_negado'
ORDER BY criado_em DESC
LIMIT 50;Monitoramento e alertas
- ✅ Cada acesso negado é logado automaticamente via
logAccessDenied() - ✅ Admin pode visualizar logs de segurança no dashboard
- ❌ Não há alerta automático para múltiplos acessos negados do mesmo usuário/IP
Reação automática
- ✅ Retorna HTTP 403 imediatamente
- ✅ Loga incidente com IP, URL, método, papéis do usuário
- ❌ Não há bloqueio progressivo para tentativas repetidas de escalação
Evidência técnica
| Arquivo | Linha(s) | Descrição |
|---|---|---|
_shared/auth-helpers.ts:160-177 | requireRole() | Middleware genérico com logging automático |
_shared/auth-helpers.ts:101-135 | logAccessDenied() | Registro de incidentes de segurança |
_shared/auth-helpers.ts:90-93 | hasRole() | Verificação case-insensitive de papel |
_shared/permissions.ts:49-56 | PERMISSIONS_BY_ROLE | Mapeamento papel → permissões |
_shared/permissions.ts:111-114 | MANDATORY_PERMISSIONS_BY_ROLE | Permissões irremovíveis por papel |
3. Isolamento Multi-Tenant
Status: ✅ Implementado
O que está implementado
RLS (Row Level Security) ativo em todas as 57 tabelas do banco (contagem da data da auditoria, 2026-02-28 — tabelas novas como
portal_rate_metrics,portal_alert_cooldown,senha_historico,canary_groups,feature_flagsforam adicionadas depois). Verificação viasupabase--linterconfirma zero tabelas sem RLS habilitado.escola_idno JWT — Claimescola_idé incluída no token JWT tanto do sistema (jwt.ts:16) quanto do portal (jwt-portal.ts:28). Policies RLS verificam:sqlauth.jwt()->>'escola_id' = escola_id::textSECURITY DEFINER functions — Para evitar recursão infinita de RLS, funções como
aluno_pertence_escola()eresponsavel_vinculado_aluno()usamSECURITY DEFINERcomsearch_path = 'public'.Tabelas sem policies (por design, contagem da data da auditoria — re-auditar periodicamente):
cadastro_tokens— acesso exclusivo viaservice_role(criação de escolas)sms_log— acesso exclusivo viaservice_role(legado Twilio, descontinuado)twilio_billing_log— acesso exclusivo viaservice_role(legado Twilio, descontinuado)- Tabelas adicionadas após auditoria (
token_blacklist,portal_rate_metrics,portal_alert_cooldown,senha_historico) também sem policies (acesso viaservice_role)
Multi-escola — Usuários podem ter papéis em múltiplas escolas. O JWT carrega
roles[]comescola_idpor papel. Bloqueios são verificados por escola individualmente emverify-otp/index.ts:380-469.
O que NÃO está implementado
Validação explícita de ownership em Edge Functions — Algumas Edge Functions confiam exclusivamente no RLS para isolamento, sem validação adicional no código da função. Se uma policy for mal-configurada, dados podem vazar.
Teste automatizado de isolamento — Não há teste E2E que verifica se escola A não consegue ver dados de escola B.✅ Implementado emtests/security/idor-cross-escola.test.ts(pós-auditoria).
Riscos
| Risco | Cenário | Impacto |
|---|---|---|
| Policy RLS defeituosa | Uma nova tabela é criada sem escola_id check no RLS | Alto |
| Service role leak | Edge Function usa createSupabaseSystem() indevidamente para operações de usuário | Alto |
Criticidade: Médio
O RLS é robusto mas depende de configuração correta em cada nova tabela.
Como testar manualmente (staging)
# Login como gestor da escola A
# Tentar acessar dados da escola B via API direta
curl -b "olp_auth=TOKEN_ESCOLA_A" \
-X POST https://[SUPABASE_URL]/functions/v1/gestao-alunos \
-H "Content-Type: application/json" \
-H "Origin: https://olp.digital" \
-d '{"action":"list","params":{"escola_id":"UUID_ESCOLA_B"}}'
# Esperado: array vazio ou erro (RLS filtra por escola_id do JWT)Como validar via logs
-- Verificar se há acessos cross-escola
SELECT
criado_em, nome_usuario, acao,
detalhes->>'escola_id' as escola_target,
ip
FROM logs_transacoes
WHERE acao = 'permissao.acesso_negado'
AND detalhes->>'tipo' = 'security'
ORDER BY criado_em DESC;Monitoramento e alertas
- ✅ RLS aplicado automaticamente pelo Postgres
- ❌ Não há monitoramento específico de tentativas cross-tenant
- ❌ Não há teste automatizado de isolamento em CI/CD
Reação automática
- ✅ RLS bloqueia silenciosamente (retorna dataset vazio)
- ✅
requireRole()verifica papel antes de processar ação
Evidência técnica
| Arquivo | Descrição |
|---|---|
_shared/supabase-client.ts:32-60 | createSupabaseClient(req) — extrai JWT do cookie para RLS |
_shared/supabase-client.ts:104-118 | createSupabaseSystem() — documentação explícita de uso restrito |
DB: aluno_pertence_escola() | SECURITY DEFINER function para RLS sem recursão |
DB: responsavel_vinculado_aluno() | SECURITY DEFINER function para RLS do portal |
4. IDOR e Enumeração de UUID
Status: ⚠️ Parcial
O que está implementado
UUIDs v4 — Todas as PKs usam
gen_random_uuid(), que gera UUIDs criptograficamente aleatórios (122 bits de entropia). Não são sequenciais ou previsíveis.RLS como defesa primária — Mesmo que um atacante conheça um UUID válido de outra escola, o RLS bloqueia acesso.
Mensagens genéricas — Endpoints de login não revelam se um CPF/INEP/CNPJ existe no sistema.
O que NÃO está implementado
Validação explícita de ownership — Nem todas as Edge Functions validam no código se o
idpassado pertence à escola do usuário. Dependem exclusivamente do RLS.Rate limiting em endpoints de consulta — Endpoints como
gestao-alunoscom actionlistnão têm rate limiting específico, permitindo enumeração massiva dentro do escopo da própria escola.
Riscos
| Risco | Cenário | Impacto |
|---|---|---|
| Enumeração intra-tenant | Coordenador malicioso enumera todos os alunos da própria escola | Baixo (acesso legítimo) |
| IDOR se RLS falhar | Policy mal configurada em nova tabela permite acesso cross-escola por UUID | Alto |
Criticidade: Médio
UUIDs v4 + RLS fornecem defesa robusta. Risco real depende de qualidade das policies RLS.
Como testar manualmente (staging)
# Tentar acessar aluno de outra escola por UUID
curl -b "olp_auth=TOKEN_ESCOLA_A" \
-X POST https://[SUPABASE_URL]/functions/v1/gestao-alunos \
-H "Content-Type: application/json" \
-d '{"action":"get","params":{"id":"UUID_ALUNO_ESCOLA_B"}}'
# Esperado: aluno não encontrado (RLS filtra)Monitoramento e alertas
- ❌ Não há detecção de tentativas de IDOR
- ❌ Não há rate limiting em consultas autenticadas
Evidência técnica
- Todas as tabelas usam
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY - RLS policies verificam
escola_idviaauth.jwt()->>'escola_id'
5. Rate Limiting
Status: ✅ Implementado (atualizado Fase A.1 — 2026-03-07)
O que está implementado
Sistema Admin (send-otp):
| Tipo | Limite | Janela | Evidência |
|---|---|---|---|
| Por usuário | 3 OTPs | 15 minutos | send-otp/index.ts:18-19, 334-354 |
| Por IP | 10 OTPs | 1 hora | send-otp/index.ts:19, 357-377 |
Portal Público (portal-escola) — Fase A.1 (NAT de alta densidade):
Estratégia dual: escola_id como chave primária (controle de burst), IP como secundário (anti-bot).
| Tipo | Limite | Janela | Chave |
|---|---|---|---|
| Lookup por IP | 500 | 1 min | IP |
| Lookup por escola | 2000 | 1 min | escola_id |
| Séries por IP | 500 | 1 min | IP |
| Login A por escola | 600 | 1 min | escola_id |
| Login A por IP | 800 | 1 min | IP |
| OTP por escola | 100 | 15 min | escola_id |
| OTP por IP | 50 | 15 min | IP |
| Check slug | 100 | 1 min | IP |
| Cadastro | 30 | 30 min | IP |
| Vínculo | 30 | 60 min | IP |
Alertas push (ntfy.sh):
- ✅ Rate limit OTP por escola atingido → alerta
highcom tagswarning,money_with_wings - ✅ Lockout severo (10+ falhas) → alerta
highcom tagsrotating_light,lock
Validação de CPF backend:
- ✅
isValidCPF()aplicado em todos os endpoints que recebem CPF (6 chamadas emportal-escola, 1 emsend-otp, 2 emgestao-responsaveis)
O que NÃO está implementado
Rate limiting em endpoints autenticados — Nenhuma Edge Function autenticada (admin-escolas, gestao-alunos, etc.) possui rate limiting.
Rate limiting no Worker Cloudflare — O gateway não implementa rate limiting próprio.
Criticidade: Baixo (anteriormente Médio)
Reduzido de Médio para Baixo após implementação da Fase A.1 com dual check (IP + escola_id), alertas push ntfy e validação CPF backend.
Evidência técnica
| Arquivo | Descrição |
|---|---|
portal-escola/index.ts:33-51 | Constantes de rate limit Fase A.1 |
portal-escola/index.ts:87-110 | checkRateLimit() com keyField configurável |
portal-escola/index.ts | Alertas ntfy em rate limit OTP escola + lockout severo |
send-otp/index.ts:18-19 | Constantes de rate limit do sistema |
_shared/cpf-validator.ts | Validação matemática de CPF (dígitos verificadores) |
6. Lockout Progressivo
Status: ✅ Implementado (Portal)
O que está implementado
Portal público implementa lockout progressivo por identificador+IP:
| Tentativas falhas (24h) | Lockout | Evidência |
|---|---|---|
| 3 | 1 minuto | portal-escola/index.ts:30 |
| 6 | 5 minutos | portal-escola/index.ts:31 |
| 10 | 30 minutos | portal-escola/index.ts:32 |
const LOCKOUT_THRESHOLDS = [
{ attempts: 3, lockoutMinutes: 1 },
{ attempts: 6, lockoutMinutes: 5 },
{ attempts: 10, lockoutMinutes: 30 },
];Implementação em checkLockout() (portal-escola/index.ts:79-119): consulta tentativas falhas nas últimas 24h por identificador OU IP, aplica o threshold mais alto atingido.
O que NÃO está implementado
Lockout no sistema admin —
verify-otptem proteção de brute force (5 tentativas = lockout 15min) mas não tem lockout progressivo. Após os 15 minutos, o usuário pode tentar novamente sem penalidade crescente.Lockout permanente — Não há mecanismo de ban permanente após X tentativas.
Criticidade: Baixo
Lockout progressivo está presente no endpoint mais exposto (portal público). O sistema admin tem proteção básica suficiente.
Evidência técnica
| Arquivo | Linha(s) | Descrição |
|---|---|---|
portal-escola/index.ts:29-33 | LOCKOUT_THRESHOLDS | 3 níveis de lockout |
portal-escola/index.ts:79-119 | checkLockout() | Lógica de lockout progressivo |
verify-otp/index.ts:46-47 | Constantes | MAX_FAILED_ATTEMPTS = 5, LOCKOUT_MINUTES = 15 |
7. Proteção contra Brute Force
Status: ✅ Implementado
O que está implementado
verify-otp (sistema admin):
- Máximo de 5 tentativas falhas em 15 minutos = lockout de 15 minutos
- OTP invalidado após 3 erros no mesmo código (
verify-otp/index.ts:282-288) - Tentativas falhas contabilizadas por
usuario_id, não por IP - Incidente de bloqueio logado como
auth.bloqueio_ipcom detalhes de segurança
// Invalidar OTP se atingiu 3 tentativas falhas neste código específico
if (newAttempts >= 3) {
await supabase
.from('login_otps')
.update({ usado_em: new Date().toISOString() })
.eq('id', latestOtp.id);
console.warn(`🔒 OTP ${latestOtp.id} invalidado após 3 tentativas falhas`);
}portal-escola (portal público):
- Lockout progressivo (ver seção 6)
- Cada tentativa registrada em
portal_login_tentativas - Mensagem genérica em caso de falha
O que NÃO está implementado
- CAPTCHA — Nenhum endpoint usa CAPTCHA ou desafio similar após múltiplas falhas.
- Notificação ao usuário — O titular da conta não é notificado sobre tentativas falhas contra sua credencial.
Criticidade: Baixo
Proteção adequada para o perfil de risco da aplicação.
Evidência técnica
| Arquivo | Linha(s) | Descrição |
|---|---|---|
verify-otp/index.ts:46-47 | Constantes | MAX_FAILED_ATTEMPTS = 5, LOCKOUT_MINUTES = 15 |
verify-otp/index.ts:188-236 | Brute force check | Soma tentativas falhas em OTPs recentes |
verify-otp/index.ts:259-298 | OTP invalido | Incrementa contador + invalida OTP após 3 erros |
8. CORS
Status: ✅ Implementado
O que está implementado
CORS configurado em _shared/cors-helpers.ts com:
- Origin exata — Nunca usa
*. Retorna a origin exata da requisição se permitida. - Whitelist estática —
localhost:5173,localhost:8080, preview Lovable,olp.digital - Whitelist dinâmica — Qualquer subdomínio de
.lovableproject.com,.lovable.appe.olp.digital - Credentials —
Access-Control-Allow-Credentials: true(obrigatório para cookies) - Preflight cache —
Access-Control-Max-Age: 86400(24h) - Headers permitidos —
authorization, x-client-info, apikey, content-type, cookie, accept - Fallback — Se origin não reconhecida, usa
http://localhost:5173(desenvolvimento)
export function isOriginAllowed(origin: string | null): boolean {
if (!origin) return false;
if (origin.endsWith('.lovableproject.com') || origin.endsWith('.lovable.app')) return true;
if (origin === 'https://olp.digital' || origin.endsWith('.olp.digital')) return true;
return ALLOWED_ORIGINS.includes(origin);
}- Todas as respostas incluem CORS — Padrão seguido em todas as Edge Functions (sucesso E erro).
O que NÃO está implementado
- Validação de Referer — Apenas Origin é verificada. Referer não é validado como defesa adicional.
Criticidade: Baixo
CORS está corretamente implementado para o cenário de cookies HttpOnly + cross-origin.
Como testar manualmente (staging)
# Testar CORS com origin válida
curl -v -X OPTIONS https://[SUPABASE_URL]/functions/v1/me \
-H "Origin: https://olp.digital" \
-H "Access-Control-Request-Method: POST"
# Esperado: 204 com Access-Control-Allow-Origin: https://olp.digital
# Testar CORS com origin inválida
curl -v -X OPTIONS https://[SUPABASE_URL]/functions/v1/me \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: POST"
# Esperado: Access-Control-Allow-Origin: http://localhost:5173 (fallback)
# Navegador bloqueará a requisição cross-originEvidência técnica
| Arquivo | Linha(s) | Descrição |
|---|---|---|
_shared/cors-helpers.ts:11-19 | ALLOWED_ORIGINS | Lista estática de origins |
_shared/cors-helpers.ts:24-38 | isOriginAllowed() | Validação com wildcard para subdomínios |
_shared/cors-helpers.ts:43-65 | getCorsHeaders() | Gera headers com origin exata |
_shared/cors-helpers.ts:70-74 | handleCorsPrelight() | Resposta 204 para OPTIONS |
9. Headers de Segurança HTTP
Status: ✅ Implementado (atualizado 2026-03-01)
O que está implementado
Access-Control-*headers (via cors-helpers.ts)Set-Cookiecom flags de segurança (HttpOnly; Secure; SameSite=None)- ✅ Meta tags de segurança no
index.html(implementado 2026-03-01):Content-Security-Policycom política restritiva (self + backends explícitos)X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
- ✅ Headers HTTP reais no Cloudflare Worker (implementado 2026-03-01, verificado via
curl -I):Strict-Transport-Security: max-age=31536000; includeSubDomainsX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), payment=()Content-Security-Policy: default-src 'none'; frame-ancestors 'none'(CSP restritiva para API JSON)
O que NÃO está implementado
- HSTS Preload — O domínio não foi submetido ao hstspreload.org. O HSTS funciona normalmente (browser lembra por 1 ano após primeira visita), mas a primeira requisição de um usuário novo ainda passa por HTTP antes do redirect. Submissão é opcional e praticamente irreversível (3-6 meses para remover).
CSP Detalhada
Frontend (index.html — meta tag):
default-src 'self';
script-src 'self' 'unsafe-inline'; ← Necessário para Vite (builds inline)
style-src 'self' 'unsafe-inline'; ← Necessário para Tailwind/shadcn
img-src 'self' https: data: blob:; ← Storage externo + data URIs
font-src 'self' data:;
connect-src 'self' https://*.supabase.co https://gateway.olp.digital https://olp.digital https://*.olp.digital https://ai.gateway.lovable.dev;
frame-ancestors 'none'; ← Anti-clickjackingAPI (Worker — HTTP header):
default-src 'none'; frame-ancestors 'none'Adequada porque o Worker só retorna JSON — nenhum recurso deve ser carregado.
Riscos Residuais
| Risco | Status | Notas |
|---|---|---|
| Clickjacking | ✅ Mitigado | frame-ancestors 'none' + X-Frame-Options: DENY em meta tag e Worker |
| XSS via terceiros | ✅ Mitigado | CSP restringe scripts a 'self' |
| MIME sniffing | ✅ Mitigado | X-Content-Type-Options: nosniff em meta tag e Worker |
| Downgrade HTTPS | ✅ Mitigado | HSTS ativo no Worker (max-age=31536000; includeSubDomains). Janela mínima na primeira visita (mitigada por redirect 301 do Cloudflare). Preload opcional. |
Criticidade: Baixo (anteriormente Médio → Alto)
Reduzido de Médio para Baixo após implementação dos headers HTTP no Worker. Todos os 6 headers de segurança estão ativos tanto no frontend (meta tags) quanto na API (Worker headers). Único ponto opcional: submissão no hstspreload.org.
Evidência técnica
| Arquivo | Descrição |
|---|---|
index.html | Meta tags de segurança (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) |
| Cloudflare Worker (externo) | Objeto SECURITY_HEADERS com 6 headers injetados em todas as respostas |
docs/architecture/CLOUDFLARE_WORKER_GATEWAY.md seção 9 | Documentação completa dos headers implementados |
_shared/cors-helpers.ts | CORS headers (inalterado) |
10. Proteção contra Replay de Token
Status: ✅ Implementado (2026-03-01)
O que está implementado
- Expiração temporal do JWT — 8h admin, 2h portal
- Cookie HttpOnly — Previne captura via XSS
jti(JWT ID) — Cada token recebe identificador único viacrypto.randomUUID()- Tabela
token_blacklist— Tokens revogados no logout são inseridos com TTL de 8h - Verificação de blacklist —
verifyAuthToken()consultatoken_blacklistantes de aceitar token
O que NÃO está implementado
- Token binding — Token não está vinculado a fingerprint do dispositivo, IP ou User-Agent.
- Nonce/counter — Não há mecanismo para detectar reuso sequencial.
Riscos
| Risco | Cenário de Ataque | Impacto |
|---|---|---|
| Replay de token | Atacante captura token via rede (MITM) e o reutiliza antes do logout. Após logout, token é blacklisted. | Baixo |
Criticidade: Baixo
Mitigado por HTTPS obrigatório (Secure flag), expiração curta, e revogação via blacklist no logout.
Evidência técnica
| Arquivo | Descrição |
|---|---|
_shared/jwt.ts | signAuthToken() inclui jti claim via crypto.randomUUID() |
_shared/jwt.ts | verifyAuthToken() consulta token_blacklist para validar jti |
logout/index.ts | Insere jti na token_blacklist com TTL de 8h |
11. Logs Estruturados
Status: ✅ Implementado
O que está implementado
Sistema de logging centralizado via registrarLog() em _shared/logging-helper.ts:
- Geolocalização automática — Exclusivamente via Cloudflare Worker headers (
X-Geo-City,X-Geo-Region,X-Geo-Country) - IP extraction — Cadeia de headers:
x-olp-client-ip>x-real-ip>cf-connecting-ip>x-forwarded-for - Parâmetro
req: reqobrigatório — Todas as chamadas devem incluir o request para extração de geodados do Cloudflare Worker - Diff automático —
gerarAlteracoes()emdiff-helper.tspara operações UPDATE
Formato padrão de ação: modulo.operacao (ex: login.success, escola.create, permissao.acesso_negado)
Campos da tabela logs_transacoes:
usuario_id,nome_usuario,papel_principal,acaoip,cidade,estado,paisdetalhes(JSONB com tipo, entidade, entidadeId, resumo/alterações)
O que NÃO está implementado
- Logs de SELECT/READ — Trade-off consciente documentado. Não há registro de consultas.
- Log forwarding — Logs ficam apenas no Supabase. Não há integração com serviços de log externo (Datadog, Sentry, etc.).
- Retenção automática — Não há política de expiração/arquivamento de logs antigos em
logs_transacoes.
Criticidade: Baixo
Logging está bem implementado para auditoria e debugging.
Como validar via logs
-- Distribuição de ações por dia
SELECT
DATE(criado_em) as dia,
acao,
COUNT(*) as total
FROM logs_transacoes
WHERE criado_em >= NOW() - INTERVAL '7 days'
GROUP BY dia, acao
ORDER BY dia DESC, total DESC;
-- Verificar se logs têm geolocalização
SELECT
COUNT(*) FILTER (WHERE cidade IS NOT NULL) as com_geo,
COUNT(*) FILTER (WHERE cidade IS NULL) as sem_geo
FROM logs_transacoes
WHERE criado_em >= NOW() - INTERVAL '7 days';Evidência técnica
| Arquivo | Linha(s) | Descrição |
|---|---|---|
_shared/logging-helper.ts:37-58 | extractIP() | Cadeia de headers para IP real |
_shared/logging-helper.ts:64-70 | extractGeoFromHeaders() | Geodados do Cloudflare Worker |
_shared/logging-helper.ts:79-200 | obterLocalizacao() | Priorização: CF > Cache > FreeIPAPI |
_shared/logging-helper.ts:206-247 | registrarLog() | Inserção com geoloc + service_role |
_shared/diff-helper.ts:19-53 | gerarAlteracoes() | Diff para operações UPDATE |
12. Monitoramento e Alertas
Status: ✅ Implementado
O que está implementado
CRON monitoring — Tabela
cron_statuscom campos:status:operacional|alerta|criticotentativas_consecutivas: contador de falhas seguidassms_critico_enviado_em: cooldown de 2h para SMS
Escalação automática (
maintenance-cron/index.ts:188-312):- Falha 1-2: status
alerta, retry em 15 minutos - Falha 3+: status
critico, SMS para todos os admins (viaenviarSMSCriticoAdmins()) - Cooldown de 2h entre SMSs críticos
- Alertas in-app (notificações) para todos os admins
- Falha 1-2: status
Dashboard de CRON — Componente
admin-cron-monitor.tsxcom visualização de status, última execução, erros.Logs de falha persistentes — Falhas de SMS crítico e cooldown são logadas em
logs_transacoes:manutencao.sms_critico_falhamanutencao.sms_critico_cooldown
Alertas push (ntfy.sh) para portal — Implementado em Fase B (2026-03-07):
- Lockout severo (≥10 falhas): alerta de prioridade alta via
enviarAlertaPush() - Rate limit de OTP por escola atingido (100/15min): alerta para detectar abuso de custo SMS
- Lockout severo (≥10 falhas): alerta de prioridade alta via
O que NÃO está implementado
- Monitoramento de latência de Edge Functions — Não há tracking de P95/P99 de tempo de resposta.
- Health check externo — Não há serviço externo (UptimeRobot, Pingdom) verificando disponibilidade.
- Alertas de segurança adicionais — Não há alerta automático para:
- Pico de
permissao.acesso_negado - Login de geolocalização atípica
- Pico de
- Dashboard de segurança — Admin vê logs genéricos mas sem dashboard focado em incidentes de segurança.
Criticidade: Baixo-Médio
Monitoramento operacional está bom. Alertas push de segurança no portal implementados. Faltam alertas em endpoints autenticados.
Evidência técnica
| Arquivo | Descrição |
|---|---|
maintenance-cron/index.ts:86-143 | enviarSMSCriticoAdmins() |
maintenance-cron/index.ts:148-183 | criarAlertaAdmins() |
maintenance-cron/index.ts:188-312 | escalarFalhaCron() — lógica completa de escalação |
13. Segurança de Dependências (Supply Chain)
Status: ✅ Implementado (2026-03-01)
O que está implementado
- Frontend (npm): Dependências fixadas em
package.jsoncombun.lockb - Backend (Deno): Imports pinados por versão (ex:
https://deno.land/std@0.168.0/http/server.ts,npm:@supabase/supabase-js@2) - Dependabot configurado —
.github/dependabot.ymlcom atualizações automáticas de segurança para npm - CI audit —
.github/workflows/audit.ymlexecutanpm audit --audit-level=highem cada PR - Migração xlsx → ExcelJS — Pacote
xlsx(CVE-2023-30533) removido e substituído porexceljs(2026-03-01)
O que NÃO está implementado
- Hash de integridade — Deno imports não usam
--lockou hash de integridade - SBOM — Sem Software Bill of Materials gerado
- Deno audit — Sem verificação automatizada de imports Deno
Criticidade: Baixo
Dependências fixadas, Dependabot ativo, CI audit configurado, e vulnerabilidade crítica (xlsx) migrada.
Evidência técnica
| Arquivo | Descrição |
|---|---|
.github/dependabot.yml | Configuração Dependabot para npm |
.github/workflows/audit.yml | CI pipeline com npm audit |
package.json | ExcelJS substituiu xlsx |
14. Proteção contra Scraping e DoS
Status: ⚠️ Parcial
O que está implementado
Rate limiting por IP — Em endpoints públicos (send-otp, portal-escola). Ver seção 5.
Cloudflare como proxy — Supabase Edge Functions rodam atrás da infra Cloudflare (proteção DDoS básica de camada 3/4).
Gateway Cloudflare Worker — Em produção, requests passam pelo Worker OLP que herda proteções de rede do Cloudflare.
Rate limiting NAT-aware (Fase A.1) — Dual check por IP +
escola_idcom janelas curtas de 1 minuto, protegendo endpoints públicos do portal contra abuso em ambientes de alta densidade (escolas com NAT compartilhado). Ver seção 5 edocs/security/RATE_LIMITS.md.
O que NÃO está implementado
- WAF (Web Application Firewall) — Não há WAF configurado no Cloudflare
- CAPTCHA — Nenhum endpoint usa CAPTCHA (planejado para Fase E com Cloudflare Turnstile)
- Device fingerprinting — Não há identificação de dispositivo além do IP
- Bot detection — Não há detecção de comportamento automatizado
- Request body size limit — Não há validação de tamanho do body nas Edge Functions
Riscos
| Risco | Cenário | Impacto |
|---|---|---|
| Scraping de dados públicos | Bot enumera slugs de escolas via lookup_escola | Baixo (dados não sensíveis, rate limited 2000/min) |
| DoS em Edge Function | Requests com payloads grandes ou queries complexas | Médio |
| Proxy rotation bypass | Atacante usa rede de proxies para bypass de rate limit por IP | Médio (mitigado por dual check escola_id) |
Criticidade: Baixo-Médio
Proteção robusta via rate limiting NAT-aware, lockout progressivo, e Cloudflare. CAPTCHA planejado (Fase E).
Evidência técnica
- Rate limiting: ver seção 5
- Cloudflare: Worker gateway em
docs/architecture/CLOUDFLARE_WORKER_GATEWAY.md - Ausência de WAF/CAPTCHA: nenhum arquivo de configuração encontrado
15. Segurança de Secrets e Variáveis de Ambiente
Status: ✅ Implementado
O que está implementado
Secrets configurados no Supabase (Edge Functions):
| Secret | Propósito | Sensibilidade |
|---|---|---|
OLP_JWT_SECRET | Assinatura JWT do sistema e portal (unificado — PostgREST exige secret único para RLS) | 🔴 Crítico |
TWILIO_ACCOUNT_SID | Conta Twilio para SMS | 🔴 Crítico |
TWILIO_AUTH_TOKEN | Autenticação Twilio | 🔴 Crítico |
TWILIO_FROM_NUMBER | Número de origem SMS | 🟢 Baixo |
| CRON_SECRET | Autenticação de CRON jobs | 🟡 Médio | | MP_ACCESS_TOKEN | MercadoPago access token | 🔴 Crítico | | SUPABASE_SERVICE_ROLE_KEY | Bypass de RLS (uso restrito) | 🔴 Crítico |
Medidas implementadas:
- Nenhum secret no código-fonte — Todos os secrets são acessados via
Deno.env.get() - Frontend não tem acesso a secrets privados — Apenas
VITE_SUPABASE_URLeVITE_SUPABASE_PUBLISHABLE_KEY(chaves públicas por design) - Service role key restrito — Documentação explícita no código (
supabase-client.ts:93-101) sobre uso exclusivo para logging e operações de sistema
O que NÃO está implementado
- Rotação de secrets — Sem política ou mecanismo de rotação automática
- Vault/HSM — Secrets armazenados diretamente no Supabase (sem Vault externo)
- Audit de acesso a secrets — Não há log de qual Edge Function acessou qual secret
- Alerta de secret leak — Não há monitoramento de exposição de secrets em logs ou repositório
Riscos
| Risco | Cenário | Impacto |
|---|
| Secret leak em logs | console.log() acidentalmente loga valor de token/secret | Alto | | Rotação impossível | Troca de OLP_JWT_SECRET invalida todas as sessões ativas | Médio |
Criticidade: Médio
Secrets estão corretamente segregados. Risco principal é ausência de rotação e presença do bypass code em produção.
Como testar manualmente (staging)
# Verificar que secrets não vazam em respostas
curl -s -X POST https://[SUPABASE_URL]/functions/v1/me \
-H "Content-Type: application/json" \
-H "Origin: https://olp.digital" | jq .
# Nenhum campo deve conter valores de secretsEvidência técnica
| Arquivo | Descrição |
|---|---|
_shared/supabase-client.ts:93-101 | Comentários de restrição de createSupabaseSystem() |
_shared/jwt.ts:34-37 | Validação de OLP_JWT_SECRET presente |
_shared/twilio-sms.ts:73-77 | Validação de credenciais Twilio |
.env | Apenas variáveis VITE_* (chaves públicas) |
Matriz de Risco Consolidada
| # | Domínio | Status | Criticidade | Prioridade de Remediação |
|---|---|---|---|---|
| 1 | Autenticação JWT | ✅ Impl. | Baixo | - |
| 2 | Autorização RBAC | ✅ Impl. | Baixo | - |
| 3 | Multi-Tenant | ✅ Impl. | Médio | P3 (testes E2E) |
| 4 | IDOR/UUID | ⚠️ Parcial | Médio | P3 |
| 5 | Rate Limiting | ✅ Impl. | Baixo | P3 (endpoints auth) |
| 6 | Lockout Progressivo | ✅ Impl. | Baixo | - |
| 7 | Brute Force | ✅ Impl. | Baixo | - |
| 8 | CORS | ✅ Impl. | Baixo | - |
| 9 | Headers HTTP | ✅ Impl. | Baixo | - |
| 10 | Replay de Token | ✅ Impl. | Baixo | - |
| 11 | Logs Estruturados | ✅ Impl. | Baixo | - |
| 12 | Monitoramento | ✅ Impl. | Baixo-Médio | P3 (alertas auth) |
| 13 | Supply Chain | ✅ Impl. | Baixo | - |
| 14 | Scraping/DoS | ⚠️ Parcial | Baixo-Médio | P3 (CAPTCHA Fase E) |
| 15 | Secrets | ✅ Impl. | Médio | P3 (rotação) |
Plano de Ação Recomendado
P1 — Imediato ✅ CONCLUÍDO
- [x]
Adicionar headers de segurança em— Meta tags CSP, X-Frame-Options, etc.index.html - [x]
Configurar headers de segurança no Cloudflare Worker— HSTS + 5 headers HTTP
P2 — Curto prazo ✅ CONCLUÍDO
- [x]
Implementar—jtino JWT e blacklist de tokenstoken_blacklistcom TTL 8h (2026-03-01) - [x]
Configurar Dependabot no repositório—.github/dependabot.yml(2026-03-01) - [x]
Adicionar—npm auditno pipeline de CI.github/workflows/audit.yml(2026-03-01) - [x]
Migrar xlsx (CVE-2023-30533) para ExcelJS— (2026-03-01)
P3 — Médio prazo (em andamento)
- [x]
Implementar alertas push no portal (rate limit + lockout severo)— ntfy.sh (Fase B, 2026-03-07) - [x]
Validação de CPF/CNPJ em todos os endpoints—isValidCPF()(Fase C, 2026-03-07) - [x]
Remover console.logs com dados sensíveis— Fase A (2026-03-07) - [x]
Rate limiting NAT-aware no portal— Dual check IP + escola_id (Fase A.1, 2026-03-07) - [x]
Verificar/desabilitar— Removido completamente (Mar/2026)dev-bypass-loginem produção - [ ] Adicionar rate limiting em endpoints autenticados
- [ ] Criar testes E2E de isolamento multi-tenant
- [ ] Implementar política de rotação de secrets
- [ ] Adicionar CAPTCHA após múltiplas falhas de login (Fase E — Cloudflare Turnstile planejado)
- [ ] Health check externo
- [ ] Dashboard de segurança dedicado
Apêndice A: Checklist de Verificação Rápida
[✅] JWT customizado com cookies HttpOnly
[✅] Separação admin/portal com claims distintas
[✅] Fallback Authorization header REMOVIDO
[✅] RBAC com requireRole() em todas as Edge Functions
[✅] logAccessDenied() automático
[✅] RLS ativo em 100% das tabelas (57)
[✅] Rate limiting em endpoints públicos (NAT-aware dual check)
[✅] Lockout progressivo no portal
[✅] Brute force protection no verify-otp
[✅] CORS com origin exata (nunca *)
[✅] OTP hasheado com SHA-256
[✅] OTP com expiração de 5 minutos
[✅] Mensagens genéricas (não revela existência de usuário)
[✅] Logs estruturados com geolocalização
[✅] Monitoramento CRON com escalação SMS
[✅] Cookies de logout em múltiplos domínios
[✅] Headers de segurança HTTP (CSP, X-Frame-Options, HSTS) — meta tags + Worker
[✅] Revogação server-side de JWT (jti + token_blacklist)
[✅] Anti-replay (jti)
[✅] Audit automatizado de dependências (Dependabot + CI audit)
[✅] Alertas push portal (ntfy.sh — lockout severo + rate limit OTP)
[✅] Validação CPF/CNPJ em endpoints críticos
[✅] Console.logs sensíveis removidos
[❌] CAPTCHA (planejado Fase E — Cloudflare Turnstile)
[❌] WAF
[❌] Health check externo
[❌] Rotação de secrets
[❌] Rate limiting em endpoints autenticadosRelatório gerado automaticamente via análise de código-fonte. Revisão manual recomendada para validação de políticas RLS individuais e configurações de infraestrutura não acessíveis via código.