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
@@ -22,6 +22,8 @@ from core.flask_app import app
|
|||||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
||||||
import routes # noqa: F401 - enregistre toutes les routes sur `app`
|
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 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():
|
def _open_browser():
|
||||||
|
|||||||
@@ -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 <form> 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
|
||||||
@@ -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 <form> 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
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<title>{% block title %}Forge Engine{% endblock %}</title>
|
<title>{% block title %}Forge Engine{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
|
||||||
|
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ game.name }} — Jouer</title>
|
<title>{{ game.name }} — Jouer</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
|
||||||
|
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
|
||||||
<link rel="stylesheet" href="{{ google_fonts_link }}">
|
<link rel="stylesheet" href="{{ google_fonts_link }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"><style>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"><style>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from core.flask_app import app as flask_app
|
|||||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
|
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
|
||||||
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
||||||
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
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 (désactivée en TESTING, voir test_csrf.py)
|
||||||
import auth
|
import auth
|
||||||
import db
|
import db
|
||||||
|
|
||||||
|
|||||||
@@ -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