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:
@ -498,3 +498,102 @@ def test_force_secure_cookies_config_overrides_scheme():
|
||||
finally:
|
||||
cfg["force_secure_cookies"] = False
|
||||
models.save_config(cfg)
|
||||
|
||||
|
||||
# ── Rate-limiting /login et /login/password ───────────────────────────────────
|
||||
# Empêche le brute-force du mot de passe et le mail-bombing SMTP.
|
||||
|
||||
def test_login_password_rate_limit_kicks_in():
|
||||
"""Au-delà de la limite, /login/password doit refuser."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
_seed_user("nina")
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 3
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
# 3 tentatives (toutes incorrectes) → autorisées
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/login/password",
|
||||
data={"email": "nina@x.fr", "password": "bad"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "incorrect" in r.text.lower() or "Mot de passe" in r.text
|
||||
# 4e tentative → bloquée
|
||||
r4 = client.post(
|
||||
"/login/password",
|
||||
data={"email": "nina@x.fr", "password": "bad"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r4.status_code == 200
|
||||
assert "Trop de tentatives" in r4.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def test_login_post_rate_limit_kicks_in():
|
||||
"""Au-delà de la limite, POST /login doit refuser (mail-bombing)."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 2
|
||||
# Config SMTP valide pour que la route ne plante pas avant send_mail
|
||||
cfg["smtp"] = {"host": "smtp.test", "port": 587, "user": "u", "password": "p", "use_tls": True}
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
# 2 POST /login OK (le mail peut échouer, mais le rate limit passe)
|
||||
for i in range(2):
|
||||
r = client.post("/login", data={"email": f"victim{i}.x@x.fr"})
|
||||
assert "Trop de tentatives" not in r.text
|
||||
# 3e POST → bloqué
|
||||
r3 = client.post("/login", data={"email": "victim3.x@x.fr"})
|
||||
assert "Trop de tentatives" in r3.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def test_rate_limit_independent_per_email():
|
||||
"""Le rate limit /login/password est par (ip, email) : deux emails distincts
|
||||
ne se partagent pas leur quota."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 2
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
_seed_user("oscar")
|
||||
# 2 tentatives sur "oscar" → OK
|
||||
for _ in range(2):
|
||||
client.post("/login/password", data={"email": "oscar@x.fr", "password": "x"}, follow_redirects=False)
|
||||
# La 3e sur "oscar" doit échouer
|
||||
r_o = client.post("/login/password", data={"email": "oscar@x.fr", "password": "x"}, follow_redirects=False)
|
||||
assert "Trop de tentatives" in r_o.text
|
||||
# Une autre adresse email doit encore fonctionner
|
||||
_seed_user("paul")
|
||||
r_p = client.post("/login/password", data={"email": "paul@x.fr", "password": "x"}, follow_redirects=False)
|
||||
assert "Trop de tentatives" not in r_p.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def test_rate_limit_can_be_disabled_via_config():
|
||||
"""config.login_rate_limit_per_min = 0 doit désactiver le rate limit."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 0
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
_seed_user("quinn")
|
||||
# Beaucoup de tentatives : aucune ne doit être bloquée
|
||||
for _ in range(50):
|
||||
r = client.post("/login/password", data={"email": "quinn@x.fr", "password": "x"}, follow_redirects=False)
|
||||
assert "Trop de tentatives" not in r.text, r.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
Reference in New Issue
Block a user