- app/rate_limit.py: bloqueia por IP após 5 tentativas de senha erradas em 5 minutos em POST /login (rota pré-autenticação, hoje sem nenhuma proteção contra força bruta). - Cookie de sessão (routers/auth.py) e cookie CSRF (main.py) passam a sair com Secure quando a requisição chega em HTTPS (request.url.scheme) — hoje é sempre False porque produção ainda serve HTTP puro, mas o cookie passa a ficar protegido automaticamente assim que um TLS for colocado na frente, sem precisar editar isso de novo. - main.py: novo middleware de security headers (X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy, Content-Security-Policy, Strict-Transport-Security condicional a HTTPS). - requirements.txt: fastapi 0.115.0 -> 0.115.6 (starlette >=0.40.0, corrige DoS por memória não limitada ao parsear multipart/form-data — GHSA-f96h-pmfr-66vw), python-multipart 0.0.9 -> 0.0.20 (várias vulnerabilidades de DoS corrigidas), jinja2 3.1.4 -> 3.1.6 (escapes de sandbox). httpx/uvicorn/PyMySQL já estavam em versões sem CVE conhecida — conferido via osv.dev. - Dockerfile: container não roda mais como root. Suíte completa (181 testes) passando, incluindo testes novos para o rate limiter (unitários + integração via POST /login) e smoke test manual confirmando headers, fluxo de login com CSRF e o bloqueio por força bruta. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
53 lines
2.2 KiB
Python
53 lines
2.2 KiB
Python
"""
|
|
Limitador de tentativas de login por IP de origem — proteção simples
|
|
contra força bruta de senha em POST /login (app/routers/auth.py), que é
|
|
uma rota pré-autenticação e portanto alcançável por qualquer visitante.
|
|
|
|
Em memória (dict por processo), no mesmo espírito de app/session.py (chave
|
|
de assinatura em memória) e app/db/connection.py (sem pool): este painel
|
|
roda como container único, sem múltiplas réplicas atrás de um load
|
|
balancer, então não precisa de armazenamento compartilhado (Redis etc.)
|
|
para isso funcionar. Reinício do processo zera o histórico — efeito
|
|
colateral aceito, mesma lógica do SESSION_SECRET em memória.
|
|
|
|
Se um dia o painel passar a rodar atrás de um proxy reverso, a origem
|
|
(hoje request.client.host, lida em app/routers/auth.py) precisa passar a
|
|
vir de X-Forwarded-For validado contra uma lista de proxies confiáveis —
|
|
sem isso, toda tentativa apareceria vindo do IP do proxy e o limite
|
|
passaria a valer para todo mundo junto (um IP externo malicioso poderia
|
|
bloquear logins de todo mundo).
|
|
"""
|
|
import time
|
|
|
|
MAX_TENTATIVAS = 5
|
|
JANELA_SEGUNDOS = 5 * 60 # 5 minutos
|
|
|
|
_falhas: dict[str, list[float]] = {}
|
|
|
|
|
|
def _recentes(origem: str, momento: float) -> list[float]:
|
|
limite = momento - JANELA_SEGUNDOS
|
|
return [t for t in _falhas.get(origem, []) if t > limite]
|
|
|
|
|
|
def registrar_falha(origem: str, agora: float | None = None) -> None:
|
|
"""Chamado a cada tentativa de login com credencial inválida."""
|
|
momento = agora if agora is not None else time.time()
|
|
recentes = _recentes(origem, momento)
|
|
recentes.append(momento)
|
|
_falhas[origem] = recentes
|
|
|
|
|
|
def bloqueado(origem: str, agora: float | None = None) -> bool:
|
|
"""True quando essa origem já teve MAX_TENTATIVAS falhas nos últimos
|
|
JANELA_SEGUNDOS — POST /login deve recusar antes mesmo de checar a
|
|
credencial enviada."""
|
|
momento = agora if agora is not None else time.time()
|
|
return len(_recentes(origem, momento)) >= MAX_TENTATIVAS
|
|
|
|
|
|
def limpar(origem: str) -> None:
|
|
"""Chamado após um login bem-sucedido — não faz sentido continuar
|
|
contando falhas antigas contra quem acabou de provar a senha certa."""
|
|
_falhas.pop(origem, None)
|