"""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 = [ '">', '">', "'/>", ] 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 "