Ajoute une protection CSRF stateless par vérification de l'Origin

Les routes POST/PUT/DELETE/PATCH n'avaient aucune protection CSRF explicite,
et reposaient uniquement sur le cookie SameSite=Lax (insuffisant pour les
navigateurs anciens). L'endpoint /login était particulièrement exposé car
déclenchable depuis n'importe quel site → mail-bombing SMTP.

Ajout d'un middleware qui, pour toute requête mutative, valide le header
Origin (ou Referer en fallback) quand il est présent : son hostname doit
correspondre au host attendu (config.base_url si définie, sinon le Host de
la requête). Une correspondance absente renvoie 403.

L'absence d'Origin est tolérée pour ne pas casser les clients non-navigateur
(Tasker, curl) qui ne l'envoient pas ; SameSite=Lax reste la protection
primaire pour ces cas.

Les nouveaux tests couvrent le rejet cross-site (Origin et Referer), le
cas légitime (Origin == Host), l'absence d'Origin (acceptée), et
l'immunité des GET.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-20 00:30:47 +02:00
parent c86e1aaa13
commit 91623d5f98
2 changed files with 97 additions and 0 deletions

View File

@ -40,6 +40,42 @@ logger = logging.getLogger("pointeuse")
app = FastAPI(title="Décompte Horaire")
@app.middleware("http")
async def csrf_origin_check(request: Request, call_next):
"""Protection CSRF stateless sur les requêtes mutatives.
Stratégie défense-en-profondeur combinée au cookie SameSite=Lax :
si la requête porte un header Origin ou Referer, on vérifie que son host
correspond à celui attendu (config.base_url si définie, sinon le Host de
la requête). Une requête cross-site aura un Origin différent.
L'absence d'Origin est tolérée (cas des clients non-navigateurs comme
Tasker ou curl, où SameSite=Lax suffit).
"""
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
origin = request.headers.get("origin") or request.headers.get("referer")
if origin:
try:
parsed = urlsplit(origin)
except ValueError:
return JSONResponse({"error": "csrf"}, status_code=403)
origin_host = (parsed.hostname or "").lower()
configured_base = (load_config().get("base_url") or "").strip()
if configured_base:
try:
expected = (urlsplit(configured_base).hostname or "").lower()
except ValueError:
expected = ""
else:
expected = (request.headers.get("host") or "").lower().split(":", 1)[0]
if origin_host and expected and origin_host != expected:
logger.warning(
"CSRF: origin rejettée origin=%r host=%r path=%r",
origin, request.headers.get("host"), request.url.path,
)
return JSONResponse({"error": "csrf"}, status_code=403)
return await call_next(request)
@app.on_event("startup")
async def _log_config_at_startup():
cfg = load_config()