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
+7
View File
@@ -3,5 +3,12 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
# Não roda como root dentro do container — reduz o estrago de uma
# eventual RCE (mesmo sem vulnerabilidade conhecida hoje, é uma
# mitigação de baixo custo e sem efeito colateral aqui, já que a app não
# precisa gravar nada no filesystem além de escutar a porta 8000, que não
# é privilegiada).
RUN useradd --no-create-home --shell /usr/sbin/nologin painel
USER painel
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+40 -1
View File
@@ -45,11 +45,50 @@ async def csrf_cookie_middleware(request: Request, call_next):
if precisa_definir_cookie: if precisa_definir_cookie:
response.set_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 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") @app.middleware("http")
async def carregar_usuario_middleware(request: Request, call_next): 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 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 app/auth.py) — quem não está logado é redirecionado para GET /login pelo
exception handler de NaoAutenticado (registrado em app/main.py). 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 typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -10,7 +20,7 @@ from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from .. import session from .. import rate_limit, session
from ..auth import UsuarioLogado, autenticar_credenciais, exigir_login from ..auth import UsuarioLogado, autenticar_credenciais, exigir_login
from ..csrf import exigir_csrf, token_do_contexto from ..csrf import exigir_csrf, token_do_contexto
@@ -18,6 +28,15 @@ router = APIRouter()
templates = Jinja2Templates(directory="app/templates") 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: def _proximo_seguro(next_: Optional[str]) -> str:
"""Só aceita um path interno (começa com "/", sem "//") — nunca uma URL """Só aceita um path interno (começa com "/", sem "//") — nunca uma URL
externa, para não virar um open redirect via POST /login?next=...""" externa, para não virar um open redirect via POST /login?next=..."""
@@ -40,18 +59,35 @@ def login(
senha: str = Form(...), senha: str = Form(...),
next: Optional[str] = Form(None), 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) usuario_logado = autenticar_credenciais(usuario, senha)
if usuario_logado is None: if usuario_logado is None:
rate_limit.registrar_falha(origem)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"login.html", "login.html",
{"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."}, {"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."},
) )
rate_limit.limpar(origem)
destino = _proximo_seguro(next) destino = _proximo_seguro(next)
resposta = RedirectResponse(url=destino, status_code=303) resposta = RedirectResponse(url=destino, status_code=303)
token = session.criar_token(usuario_logado.login, usuario_logado.nome, usuario_logado.perfil) 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 return resposta
+15 -3
View File
@@ -1,7 +1,19 @@
fastapi==0.115.0 # Versões mínimas fixadas por segurança (ver auditoria de 2026-08-15):
# - fastapi 0.115.6 traz starlette >=0.40.0, que corrige um DoS por
# consumo de memória não limitado ao parsear multipart/form-data
# (GHSA-f96h-pmfr-66vw / equivalente ao CVE-2024-47874) — POST /login
# é uma rota pré-autenticação, então esse endpoint é alcançável por
# qualquer visitante, autenticado ou não.
# - python-multipart 0.0.9 tinha várias vulnerabilidades de DoS (boundary
# malformado, headers de parte sem limite, preambulo/epílogo sem
# limite) corrigidas em versões posteriores — pinado na última estável.
# - jinja2 3.1.6 corrige escapes do sandbox (baixo risco aqui, já que o
# painel não renderiza template vindo de entrada do usuário, mas sem
# custo de compatibilidade manter em dia).
fastapi==0.115.6
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
jinja2==3.1.4 jinja2==3.1.6
python-multipart==0.0.9 python-multipart==0.0.20
PyMySQL==1.1.1 PyMySQL==1.1.1
python-dotenv==1.0.1 python-dotenv==1.0.1
httpx==0.27.2 httpx==0.27.2
+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 import Depends, FastAPI
from fastapi.testclient import TestClient 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.auth import NaoAutenticado, UsuarioLogado, exigir_login
from app.routers import auth as router_auth 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" 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(): def test_post_logout_apaga_cookie_e_redireciona():
cookies = {session.SESSION_COOKIE_NAME: session.criar_token("admin", "Admin", "ADMIN")} cookies = {session.SESSION_COOKIE_NAME: session.criar_token("admin", "Admin", "ADMIN")}
resposta = client.post("/logout", data={"csrf_token": CSRF_TESTE}, cookies=cookies) resposta = client.post("/logout", data={"csrf_token": CSRF_TESTE}, cookies=cookies)