Ajoute un rate-limiting stateful sur /login et /login/password
Les routes d'authentification n'avaient aucune limite de débit :
- POST /login permettait le mail-bombing SMTP vers n'importe quelle adresse
du domaine autorisé (CSRF + flood).
- POST /login/password permettait le brute-force d'un mot de passe sans
verrou (bcrypt est lent mais pas assez pour un attaquant patient).
Ajout d'un compteur stateful en mémoire (_LOGIN_ATTEMPTS), fenêtre
glissante de 60 s, configurable via config.login_rate_limit_per_min
(défaut 10, <= 0 désactive). Clés :
- "login:<ip>" pour POST /login (limite globale par IP)
- "pw:<ip>:<email>" pour POST /login/password (limite par compte)
Au-delà du quota, la route renvoie la page login avec le message
"Trop de tentatives. Réessaie dans une minute." plutôt que d'exécuter
le workflow. La limite par (ip, email) rend le brute-force d'un compte
impossible sans rotation d'IP.
Tests : déclenchement aux limites, indépendance par email, désactivation
via config, et helper main._LOGIN_ATTEMPTS.clear() pour isoler les tests.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
52
app/main.py
52
app/main.py
@ -6,6 +6,8 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import groupby
|
||||
from urllib.parse import urlsplit
|
||||
@ -192,6 +194,40 @@ def smtp_config() -> dict:
|
||||
return load_config().get("smtp", {})
|
||||
|
||||
|
||||
# ── Rate-limiting des routes d'authentification ───────────────────────────────
|
||||
# Compteur simple en mémoire, fenêtre glissante de LOGIN_WINDOW_S secondes.
|
||||
# Suffit pour bloquer le brute-force / mail-bombing sur une app mono-utilisateur.
|
||||
# Reset entre tests via main._LOGIN_ATTEMPTS.clear().
|
||||
|
||||
_LOGIN_ATTEMPTS: dict[str, list[float]] = defaultdict(list)
|
||||
LOGIN_WINDOW_S = 60
|
||||
DEFAULT_LOGIN_MAX_PER_MIN = 10
|
||||
|
||||
|
||||
def _check_login_rate_limit(key: str, max_per_min: int | None = None) -> bool:
|
||||
"""Retourne True si la tentative est autorisée, False sinon.
|
||||
|
||||
max_per_min <= 0 désactive la limite (utile pour tests ou config).
|
||||
"""
|
||||
if max_per_min is None:
|
||||
max_per_min = int(load_config().get("login_rate_limit_per_min", DEFAULT_LOGIN_MAX_PER_MIN))
|
||||
if max_per_min <= 0:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
cutoff = now - LOGIN_WINDOW_S
|
||||
recent = [t for t in _LOGIN_ATTEMPTS[key] if t > cutoff]
|
||||
_LOGIN_ATTEMPTS[key] = recent
|
||||
if len(recent) >= max_per_min:
|
||||
return False
|
||||
recent.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Retourne l'IP du client (sans confiance X-Forwarded-For par défaut)."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _safe_base_url(request: Request) -> str | None:
|
||||
"""Retourne une URL de base fiable pour construire des liens (emails, etc.).
|
||||
|
||||
@ -422,6 +458,14 @@ def login_page(request: Request):
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse)
|
||||
def login_submit(request: Request, email: str = Form("")):
|
||||
# Rate-limit par IP pour empêcher le mail-bombing SMTP et l'énumération.
|
||||
ip = _client_ip(request)
|
||||
if not _check_login_rate_limit(f"login:{ip}"):
|
||||
logger.warning("rate limit /login déclenchée: ip=%r", ip)
|
||||
return templates.TemplateResponse("login.html", _login_ctx(
|
||||
request, "email", "",
|
||||
"Trop de tentatives. Réessaie dans une minute.",
|
||||
))
|
||||
email = email.strip().lower()
|
||||
m = _EMAIL_RE.match(email)
|
||||
domain = allowed_email_domain()
|
||||
@ -477,7 +521,15 @@ def login_submit(request: Request, email: str = Form("")):
|
||||
|
||||
@app.post("/login/password", response_class=HTMLResponse)
|
||||
def login_password(request: Request, email: str = Form(""), password: str = Form("")):
|
||||
# Rate-limit par IP+email pour bloquer le brute-force d'un compte précis.
|
||||
email = email.strip().lower()
|
||||
ip = _client_ip(request)
|
||||
if not _check_login_rate_limit(f"pw:{ip}:{email}"):
|
||||
logger.warning("rate limit /login/password déclenchée: ip=%r email=%r", ip, email)
|
||||
return templates.TemplateResponse("login.html", _login_ctx(
|
||||
request, "password", email,
|
||||
"Trop de tentatives. Réessaie dans une minute.",
|
||||
))
|
||||
m = _EMAIL_RE.match(email)
|
||||
domain = allowed_email_domain()
|
||||
logger.info("POST /login/password: email=%r", email)
|
||||
|
||||
Reference in New Issue
Block a user