Dev #4
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
<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 %}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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