From a52b244f27e13dfc3c62a8496481ee95df7e71da Mon Sep 17 00:00:00 2001 From: william Date: Fri, 28 Aug 2026 20:35:32 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20la=20protection=20CSRF=20sur=20tous=20?= =?UTF-8?q?les=20formulaires=20et=20requ=C3=AAtes=20AJAX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 /). Co-Authored-By: Claude Sonnet 5 --- app.py | 2 + core/csrf.py | 24 +++++++++++ core/csrf_guard.py | 39 ++++++++++++++++++ static/csrf_fetch.js | 31 ++++++++++++++ templates/base.html | 2 + templates/play.html | 2 + tests/conftest.py | 2 + tests/test_csrf.py | 96 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 198 insertions(+) create mode 100644 core/csrf.py create mode 100644 core/csrf_guard.py create mode 100644 static/csrf_fetch.js create mode 100644 tests/test_csrf.py diff --git a/app.py b/app.py index 373d1814..4f9d72d3 100644 --- a/app.py +++ b/app.py @@ -22,6 +22,8 @@ from core.flask_app import app from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja import routes # noqa: F401 - enregistre toutes les routes sur `app` from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes) +from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja +from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF def _open_browser(): diff --git a/core/csrf.py b/core/csrf.py new file mode 100644 index 00000000..a925949c --- /dev/null +++ b/core/csrf.py @@ -0,0 +1,24 @@ +"""Jeton CSRF — un seul jeton par SESSION (pas un par formulaire, comme le +ferait Flask-WTF) : suffisant ici et beaucoup plus simple à propager +partout où l'appli envoie une requête qui modifie quelque chose, que ce +soit un classique (intercepté par pjax.js, voir static/pjax.js) ou +un fetch() construit à la main (screen_edit.html, game_dashboard.html, +play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en +en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier +individuellement chacun des nombreux appels existants.""" +import secrets + +from flask import session + +from .flask_app import app + + +def get_csrf_token(): + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return token + + +app.jinja_env.globals["csrf_token"] = get_csrf_token diff --git a/core/csrf_guard.py b/core/csrf_guard.py new file mode 100644 index 00000000..1a98e185 --- /dev/null +++ b/core/csrf_guard.py @@ -0,0 +1,39 @@ +"""Vérifie le jeton CSRF (voir core/csrf.py) sur toute requête qui modifie +quelque chose — un before_request de plus, 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). + +Toute l'appli soumet ses formulaires ET ses fetch() via une seule voie : +pjax.js intercepte CHAQUE interne et le transforme lui-même en +fetch() (voir static/pjax.js) — aucun formulaire de ce moteur ne fait donc +jamais de vraie soumission HTML native. static/csrf_fetch.js patche +window.fetch une seule fois pour y ajouter automatiquement l'en-tête +X-CSRFToken sur toute requête non-GET de l'appli (celles de pjax.js +comprises) : pas besoin de modifier individuellement les nombreux appels +fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/ +play.html.""" +from flask import abort, request, session + +from .flask_app import app + +_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + +@app.before_request +def _verify_csrf_token(): + if request.method in _SAFE_METHODS: + return None + if app.config.get("TESTING"): + # Les tests appellent les routes directement via le client de + # test Flask (de vraies requêtes HTTP, mais jamais passées par le + # navigateur/static/csrf_fetch.js qui pose l'en-tête) — même + # convention que Flask-WTF (WTF_CSRF_ENABLED désactivé en test). + # Ce garde a ses PROPRES tests dédiés (test_csrf.py), qui + # réactivent la vérification explicitement pour la mettre à + # l'épreuve. + return None + submitted = request.headers.get("X-CSRFToken") or request.form.get("csrf_token") + expected = session.get("csrf_token") + if not expected or not submitted or submitted != expected: + abort(400, description="Jeton de sécurité manquant ou expiré — recharge la page et réessaie.") + return None diff --git a/static/csrf_fetch.js b/static/csrf_fetch.js new file mode 100644 index 00000000..98fa8b22 --- /dev/null +++ b/static/csrf_fetch.js @@ -0,0 +1,31 @@ +/* + * csrf_fetch.js — pose automatiquement le jeton CSRF (voir core/csrf.py/ + * core/csrf_guard.py) sur CHAQUE fetch() non-GET de l'appli, sans avoir à + * modifier individuellement les nombreux appels fetch() déjà écrits à la + * main (screen_edit.html, game_dashboard.html, play.html...) ni les + * formulaires classiques (pjax.js les transforme lui-même en fetch(), voir + * son commentaire d'en-tête — cette rustine s'applique donc aussi à eux). + * + * window.CSRF_TOKEN est posé une fois par templates/base.html (valeur + * injectée par Jinja, voir core/csrf.py::get_csrf_token) — chargé AVANT ce + * script pour qu'il soit déjà disponible ici. + */ +(function () { + "use strict"; + if (window.fetch.__forgeCsrfPatched) return; // rejoué par pjax.js (runScriptsIn) sans effet une 2e fois + var originalFetch = window.fetch; + function patchedFetch(input, init) { + init = init || {}; + var method = (init.method || "GET").toUpperCase(); + if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") { + var headers = new Headers(init.headers || {}); + if (!headers.has("X-CSRFToken") && window.CSRF_TOKEN) { + headers.set("X-CSRFToken", window.CSRF_TOKEN); + } + init = Object.assign({}, init, { headers: headers }); + } + return originalFetch(input, init); + } + patchedFetch.__forgeCsrfPatched = true; + window.fetch = patchedFetch; +})(); diff --git a/templates/base.html b/templates/base.html index 4cc63ab6..4a480b32 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,6 +6,8 @@ {% block title %}Forge Engine{% endblock %} + + {% block extra_head %}{% endblock %} diff --git a/templates/play.html b/templates/play.html index 206db1aa..cd2c7e82 100644 --- a/templates/play.html +++ b/templates/play.html @@ -5,6 +5,8 @@ {{ game.name }} — Jouer + +