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:
36
app/main.py
36
app/main.py
@ -40,6 +40,42 @@ logger = logging.getLogger("pointeuse")
|
|||||||
app = FastAPI(title="Décompte Horaire")
|
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")
|
@app.on_event("startup")
|
||||||
async def _log_config_at_startup():
|
async def _log_config_at_startup():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|||||||
@ -337,3 +337,64 @@ def test_safe_base_url_helper_unit():
|
|||||||
assert "localhost" in main._safe_base_url(_FakeReq("localhost"))
|
assert "localhost" in main._safe_base_url(_FakeReq("localhost"))
|
||||||
# Host sans header → None
|
# Host sans header → None
|
||||||
assert main._safe_base_url(_FakeReq("")) is 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
|
||||||
|
|||||||
Reference in New Issue
Block a user