Dev #4

Merged
w-vandal merged 16 commits from dev into main 2026-08-29 04:02:11 +00:00
8 changed files with 198 additions and 0 deletions
Showing only changes of commit a52b244f27 - Show all commits
+2
View File
@@ -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():
+24
View File
@@ -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
+39
View File
@@ -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
+31
View File
@@ -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;
})();
+2
View File
@@ -6,6 +6,8 @@
<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="{{ 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 %}
</head>
<body class="{% block body_class %}{% endblock %}">
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title>
<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="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>
+2
View File
@@ -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)
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 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 db
+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)