feat: protecao CSRF (double-submit cookie) e paginacao da lista de clientes

CSRF: novo app/csrf.py + middleware em app/main.py garantem um cookie
csrf_token por navegador; todo formulario que muda estado (criar/editar/
excluir cliente, adicionar/editar/excluir contato) embute esse valor
como campo oculto, e Depends(exigir_csrf) confere no POST que o valor
enviado bate com o cookie antes de deixar a rota executar. Verificado
ponta a ponta com o app real rodando (curl): token errado -> 403,
token correto -> 303.

Paginacao: db_clientes.listar_clientes ganha offset, nova
contar_clientes(); GET /clientes aceita ?pagina=N (100 clientes por
pagina), template mostra Anterior/Proxima e o total.

53/53 testes passando (8 novos).
This commit is contained in:
Gustavo
2026-08-13 11:02:17 -03:00
parent 96cbd8a294
commit bb8fd596b1
12 changed files with 247 additions and 31 deletions
+44
View File
@@ -0,0 +1,44 @@
"""
Proteção CSRF via "double-submit cookie": o middleware registrado em
app/main.py garante que todo navegador que acessa o painel tenha um cookie
"csrf_token" (gerado uma vez, aleatório, HttpOnly). Todo formulário que
muda estado (POST) embute esse mesmo valor como campo oculto — os
templates leem de request.state.csrf_token, que o middleware preenche a
cada requisição. Toda rota POST que muda estado usa Depends(exigir_csrf),
que confere se o valor enviado no formulário bate com o valor do cookie.
Por quê isso barra CSRF: um site hostil consegue forçar o navegador da
vítima a mandar um POST para o painel (o cookie vai junto automaticamente),
mas não consegue LER o valor do cookie para replicá-lo como campo oculto do
formulário forjado — só JavaScript rodando na própria origem do painel
consegue (e não roda em outra origem, por causa do same-origin policy).
"""
import secrets
from fastapi import HTTPException, Request, status
CSRF_COOKIE_NAME = "csrf_token"
def gerar_token() -> str:
return secrets.token_urlsafe(32)
def token_do_contexto(request: Request) -> str:
"""
Lê o token que o middleware colocou em request.state.csrf_token.
Retorna "" se o middleware não estiver montado (por exemplo, testes que
sobem só um router isolado, sem app/main.py) — nesse caso o formulário
simplesmente não terá csrf_token preenchido, e exigir_csrf abaixo vai
rejeitar o POST a menos que o teste monte o cookie/campo manualmente.
"""
return getattr(request.state, "csrf_token", "")
async def exigir_csrf(request: Request) -> None:
"""Dependency para toda rota POST que muda estado."""
form = await request.form()
token_formulario = str(form.get(CSRF_COOKIE_NAME, ""))
token_cookie = request.cookies.get(CSRF_COOKIE_NAME, "")
if not token_cookie or not token_formulario or not secrets.compare_digest(token_formulario, token_cookie):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Token CSRF ausente ou inválido.")