From 91623d5f983b700f78957d18416fe00f19415882 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Mon, 20 Jul 2026 00:30:47 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20une=20protection=20CSRF=20stateless=20?= =?UTF-8?q?par=20v=C3=A9rification=20de=20l'Origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/main.py | 36 ++++++++++++++++++++++ app/tests/test_security.py | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/app/main.py b/app/main.py index 86232f6..84be412 100644 --- a/app/main.py +++ b/app/main.py @@ -40,6 +40,42 @@ logger = logging.getLogger("pointeuse") app = FastAPI(title="Décompte Horaire") +@app.middleware("http") +async def csrf_origin_check(request: Request, call_next): + """Protection CSRF stateless sur les requêtes mutatives. + + Stratégie défense-en-profondeur combinée au cookie SameSite=Lax : + si la requête porte un header Origin ou Referer, on vérifie que son host + correspond à celui attendu (config.base_url si définie, sinon le Host de + la requête). Une requête cross-site aura un Origin différent. + L'absence d'Origin est tolérée (cas des clients non-navigateurs comme + Tasker ou curl, où SameSite=Lax suffit). + """ + if request.method in ("POST", "PUT", "DELETE", "PATCH"): + origin = request.headers.get("origin") or request.headers.get("referer") + if origin: + try: + parsed = urlsplit(origin) + except ValueError: + return JSONResponse({"error": "csrf"}, status_code=403) + origin_host = (parsed.hostname or "").lower() + configured_base = (load_config().get("base_url") or "").strip() + if configured_base: + try: + expected = (urlsplit(configured_base).hostname or "").lower() + except ValueError: + expected = "" + else: + expected = (request.headers.get("host") or "").lower().split(":", 1)[0] + if origin_host and expected and origin_host != expected: + logger.warning( + "CSRF: origin rejettée origin=%r host=%r path=%r", + origin, request.headers.get("host"), request.url.path, + ) + return JSONResponse({"error": "csrf"}, status_code=403) + return await call_next(request) + + @app.on_event("startup") async def _log_config_at_startup(): cfg = load_config() diff --git a/app/tests/test_security.py b/app/tests/test_security.py index a0a4e7d..73fc356 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -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