security: rate limit no login, cookies Secure condicional, security headers e deps com CVE
- 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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0830651f4
commit
d7a08ddd8d
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user