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 + +