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
@@ -3,5 +3,12 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
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
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+40
-1
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
+15
-3
@@ -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
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.9
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
PyMySQL==1.1.1
|
||||
python-dotenv==1.0.1
|
||||
httpx==0.27.2
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user