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:
Gustavo
2026-08-15 11:17:41 -03:00
co-authored by Claude Sonnet 5
parent b0830651f4
commit d7a08ddd8d
7 changed files with 232 additions and 7 deletions
+58
View File
@@ -0,0 +1,58 @@
from app import rate_limit
def _origem_unica() -> str:
"""Cada teste usa uma chave própria — evita que o dict em memória do
módulo (compartilhado entre testes) vaze estado de um teste pro outro."""
import uuid
return f"teste-{uuid.uuid4()}"
def test_nao_bloqueado_sem_nenhuma_falha():
assert rate_limit.bloqueado(_origem_unica()) is False
def test_nao_bloqueado_abaixo_do_limite():
origem = _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS - 1):
rate_limit.registrar_falha(origem, agora=agora)
assert rate_limit.bloqueado(origem, agora=agora) is False
def test_bloqueado_ao_atingir_o_limite():
origem = _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem, agora=agora)
assert rate_limit.bloqueado(origem, agora=agora) is True
def test_falhas_fora_da_janela_nao_contam():
origem = _origem_unica()
agora = 1_000_000.0
fora_da_janela = agora - rate_limit.JANELA_SEGUNDOS - 1
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem, agora=fora_da_janela)
assert rate_limit.bloqueado(origem, agora=agora) is False
def test_limpar_reseta_o_historico():
origem = _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem, agora=agora)
assert rate_limit.bloqueado(origem, agora=agora) is True
rate_limit.limpar(origem)
assert rate_limit.bloqueado(origem, agora=agora) is False
def test_origens_diferentes_nao_se_afetam():
origem_a, origem_b = _origem_unica(), _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem_a, agora=agora)
assert rate_limit.bloqueado(origem_a, agora=agora) is True
assert rate_limit.bloqueado(origem_b, agora=agora) is False
+22 -1
View File
@@ -1,7 +1,7 @@
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from app import session
from app import rate_limit, session
from app.auth import NaoAutenticado, UsuarioLogado, exigir_login
from app.routers import auth as router_auth
@@ -86,6 +86,27 @@ def test_get_rota_protegida_sem_login_redireciona_para_login():
assert resposta.headers["location"] == "/login?next=/rota-protegida-teste"
def test_post_login_apos_muitas_falhas_e_bloqueado_mesmo_com_senha_certa():
# TestClient reporta sempre a mesma origem ("testclient") — limpa
# antes pra não herdar a falha registrada em
# test_post_login_credenciais_erradas_nao_grava_cookie() acima, e
# limpa depois pra não vazar bloqueio pros testes seguintes deste
# arquivo (que também usam essa mesma origem "testclient").
rate_limit._falhas.pop("testclient", None)
try:
for _ in range(rate_limit.MAX_TENTATIVAS):
client.post("/login", data={"usuario": "admin", "senha": "errada", "csrf_token": CSRF_TESTE})
resposta = client.post(
"/login", data={"usuario": "admin", "senha": "teste123", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 429
assert "muitas tentativas" in resposta.text.lower()
assert session.SESSION_COOKIE_NAME not in resposta.cookies
finally:
rate_limit._falhas.pop("testclient", None)
def test_post_logout_apaga_cookie_e_redireciona():
cookies = {session.SESSION_COOKIE_NAME: session.criar_token("admin", "Admin", "ADMIN")}
resposta = client.post("/logout", data={"csrf_token": CSRF_TESTE}, cookies=cookies)