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:
co-authored by
Claude Sonnet 5
parent
989e899a96
commit
a52b244f27
@@ -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)
|
||||
Reference in New Issue
Block a user