Ajoute la protection CSRF sur tous les formulaires et requêtes AJAX

Un jeton unique par session (core/csrf.py, exposé côté Jinja via
csrf_token()) est vérifié sur toute requête non-GET par un before_request
(core/csrf_guard.py), dans le même esprit que core/auth_guard.py : une
seule garde globale plutôt que de toucher aux ~90 routes existantes une
par une.

L'app entière fait déjà transiter ses formulaires par fetch() : pjax.js
intercepte chaque <form> interne et le transforme lui-même en requête
fetch (aucun usage de l'attribut d'échappement data-no-pjax nulle part
dans le repo, confirmé par grep). Il suffit donc de patcher window.fetch
UNE SEULE FOIS (static/csrf_fetch.js) pour y ajouter automatiquement
l'en-tête X-CSRFToken sur toute requête non-GET, formulaires pjax comme
fetch() écrits à la main dans screen_edit.html/game_dashboard.html/
play.html — sans modifier un seul appel existant.

La vérification est désactivée quand app.config["TESTING"] est actif
(même convention que Flask-WTF/WTF_CSRF_ENABLED), pour ne pas avoir à
ajouter le jeton aux ~170 tests existants qui appellent les routes
directement via le client de test Flask. tests/test_csrf.py réactive
volontairement la garde pour la mettre à l'épreuve pour de vrai (GET
jamais bloqué, POST sans jeton/avec mauvais jeton -> 400, POST avec le
bon jeton via l'en-tête ou le champ de formulaire -> succès).

templates/play.html reçoit les mêmes deux balises que base.html car il
est autonome (ne l'étend pas, propre <html>/<head>).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-28 20:35:32 +02:00
co-authored by Claude Sonnet 5
parent 989e899a96
commit a52b244f27
8 changed files with 198 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Vérification du jeton CSRF (core/csrf_guard.py) — DÉSACTIVÉE quand
app.config["TESTING"] est actif (voir son propre commentaire, même
convention que Flask-WTF), pour ne pas avoir à ajouter le jeton aux ~170
autres tests du moteur qui appellent les routes directement via le client
de test Flask. Ces tests-ci réactivent volontairement la vérification
(flask_app.config["TESTING"] = False) pour la mettre à l'épreuve pour de
vrai, puis la remettent dans l'état attendu par le reste de la suite."""
import pytest
from core.flask_app import app as flask_app
@pytest.fixture
def csrf_client():
"""Comme `client` (conftest.py), mais avec la vérification CSRF
RÉACTIVÉE le temps du test — connecté en tant qu'admin de test
(session_transaction), pour isoler la seule variable qui nous
intéresse ici (le jeton), sans se soucier de l'authentification."""
import tests.conftest as conftest_module
flask_app.config["TESTING"] = False
try:
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["user_id"] = conftest_module._TEST_ADMIN_ID
yield c
finally:
flask_app.config["TESTING"] = True
def _get_session_csrf_token(client):
with client.session_transaction() as sess:
return sess.get("csrf_token")
def test_get_requests_are_never_blocked_by_csrf(csrf_client):
resp = csrf_client.get("/")
assert resp.status_code == 200
def test_post_without_any_csrf_token_is_rejected(csrf_client):
resp = csrf_client.post("/games/new", data={"name": "should not be created"})
assert resp.status_code == 400
def test_post_with_a_wrong_csrf_token_is_rejected(csrf_client):
resp = csrf_client.post(
"/games/new", data={"name": "should not be created"},
headers={"X-CSRFToken": "not-the-right-token"},
)
assert resp.status_code == 400
def test_post_with_the_correct_csrf_token_succeeds(csrf_client):
# Une requête GET fait générer/exposer le jeton de CETTE session (voir
# core/csrf.py::get_csrf_token, appelé par base.html au rendu).
csrf_client.get("/")
token = _get_session_csrf_token(csrf_client)
assert token
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_ok"},
headers={"X-CSRFToken": token}, follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
def test_post_with_the_token_in_the_form_field_also_works(csrf_client):
"""Filet de sécurité pour un éventuel formulaire natif qui ne passerait
pas par static/csrf_fetch.js (aucun cas de ce genre actuellement dans
le moteur, voir core/csrf_guard.py) — le champ de formulaire
"csrf_token" reste accepté en plus de l'en-tête."""
csrf_client.get("/")
token = _get_session_csrf_token(csrf_client)
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_form_field", "csrf_token": token},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)