Ajoute une protection CSRF stateless par vérification de l'Origin

Les routes POST/PUT/DELETE/PATCH n'avaient aucune protection CSRF explicite,
et reposaient uniquement sur le cookie SameSite=Lax (insuffisant pour les
navigateurs anciens). L'endpoint /login était particulièrement exposé car
déclenchable depuis n'importe quel site → mail-bombing SMTP.

Ajout d'un middleware qui, pour toute requête mutative, valide le header
Origin (ou Referer en fallback) quand il est présent : son hostname doit
correspondre au host attendu (config.base_url si définie, sinon le Host de
la requête). Une correspondance absente renvoie 403.

L'absence d'Origin est tolérée pour ne pas casser les clients non-navigateur
(Tasker, curl) qui ne l'envoient pas ; SameSite=Lax reste la protection
primaire pour ces cas.

Les nouveaux tests couvrent le rejet cross-site (Origin et Referer), le
cas légitime (Origin == Host), l'absence d'Origin (acceptée), et
l'immunité des GET.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-20 00:30:47 +02:00
parent c86e1aaa13
commit 91623d5f98
2 changed files with 97 additions and 0 deletions

View File

@ -337,3 +337,64 @@ def test_safe_base_url_helper_unit():
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