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
+40 -1
View File
@@ -45,11 +45,50 @@ async def csrf_cookie_middleware(request: Request, call_next):
if precisa_definir_cookie:
response.set_cookie(
CSRF_COOKIE_NAME, request.state.csrf_token, httponly=True, samesite="strict"
CSRF_COOKIE_NAME, request.state.csrf_token, httponly=True, samesite="strict",
# Secure só quando a requisição já chegou em HTTPS — ver o
# comentário equivalente em app/routers/auth.py sobre o
# cookie de sessão (mesma razão, mesmo cuidado com proxy).
secure=request.url.scheme == "https",
)
return response
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""
Cabeçalhos de resposta que não dependem de nenhuma lógica de negócio,
só reduzem a superfície de ataque do navegador: impedem o painel de
ser carregado dentro de um <iframe> de outro site (clickjacking),
impedem o navegador de tentar "adivinhar" um Content-Type diferente
do declarado, e restringem de onde a página pode carregar recursos.
CSP liberado para 'self' e 'unsafe-inline': os templates usam <script>
inline (tema, sidebar, máscaras do formulário de cliente) sem nonce, e
não carregam nada de CDN externo — então 'unsafe-inline' aqui não abre
mão de proteção nenhuma que já não estivesse ausente antes (o objetivo
desta CSP é bloquear script/estilo/frame de origem EXTERNA, o vetor
mais comum de um XSS injetado virar efeito prático, ex.: exfiltrar
cookie pra outro domínio ou carregar um script de terceiro).
"""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.middleware("http")
async def carregar_usuario_middleware(request: Request, call_next):
"""
+52
View File
@@ -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)
+38 -2
View File
@@ -2,6 +2,16 @@
Rotas de login/logoff. O painel não usa mais HTTP Basic Auth (ver
app/auth.py) — quem não está logado é redirecionado para GET /login pelo
exception handler de NaoAutenticado (registrado em app/main.py).
O cookie de sessão só sai com o atributo Secure quando a requisição já
chegou em HTTPS (request.url.scheme) — hoje a produção ainda serve só
HTTP puro (sem proxy reverso com TLS na frente), então forçar Secure
sempre quebraria o login (o navegador nunca devolveria o cookie). Assim
que um TLS for colocado na frente, o cookie passa a sair Secure
automaticamente, sem precisar mexer aqui de novo. Se um proxy reverso
for adicionado, ele precisa repassar o esquema original (cabeçalho
X-Forwarded-Proto + ProxyHeadersMiddleware/--proxy-headers do uvicorn),
senão request.url.scheme sempre lê "http" mesmo com o navegador em HTTPS.
"""
from typing import Optional
from urllib.parse import urlencode
@@ -10,7 +20,7 @@ from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import session
from .. import rate_limit, session
from ..auth import UsuarioLogado, autenticar_credenciais, exigir_login
from ..csrf import exigir_csrf, token_do_contexto
@@ -18,6 +28,15 @@ router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _origem(request: Request) -> str:
"""IP de quem fez a requisição, usado como chave do limitador de
tentativas (app/rate_limit.py). "desconhecido" só ocorre em contexto
de teste sem client configurado — nesse caso todas as tentativas sem
IP caem na mesma chave, o que é aceitável (ambiente de teste, não
produção)."""
return request.client.host if request.client else "desconhecido"
def _proximo_seguro(next_: Optional[str]) -> str:
"""Só aceita um path interno (começa com "/", sem "//") — nunca uma URL
externa, para não virar um open redirect via POST /login?next=..."""
@@ -40,18 +59,35 @@ def login(
senha: str = Form(...),
next: Optional[str] = Form(None),
):
origem = _origem(request)
if rate_limit.bloqueado(origem):
return templates.TemplateResponse(
request,
"login.html",
{
"csrf_token": token_do_contexto(request), "next": next or "",
"erro": "Muitas tentativas de login. Aguarde alguns minutos e tente novamente.",
},
status_code=429,
)
usuario_logado = autenticar_credenciais(usuario, senha)
if usuario_logado is None:
rate_limit.registrar_falha(origem)
return templates.TemplateResponse(
request,
"login.html",
{"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."},
)
rate_limit.limpar(origem)
destino = _proximo_seguro(next)
resposta = RedirectResponse(url=destino, status_code=303)
token = session.criar_token(usuario_logado.login, usuario_logado.nome, usuario_logado.perfil)
resposta.set_cookie(session.SESSION_COOKIE_NAME, token, httponly=True, samesite="lax")
resposta.set_cookie(
session.SESSION_COOKIE_NAME, token, httponly=True, samesite="lax",
secure=request.url.scheme == "https",
)
return resposta