Sécurise le cookie de session par signature HMAC
Le cookie user_id était lu tel quel depuis la requête, ce qui permettait à
n'importe qui de forger `user_id=victime` dans DevTools/curl et d'accéder à
toutes les routes "authentifiées" (lecture/écriture des pointages, export,
régénération des tokens Tasker, etc.). Le mot de passe bcrypt ne servait
donc à rien.
Le cookie est maintenant au format `<user_id>.<hmac_sha256_hex>` signé avec
une clé persistée dans /data/secret.key (auto-générée, stable entre
redémarrages). La vérification utilise hmac.compare_digest pour éviter les
fuites par timing. Toutes les routes appellent _get_user qui valide la
signature à chaque requête.
Les tests de test_logs.py récupèrent désormais le cookie réellement signé
par le serveur, et un nouveau module test_security.py couvre le rejet des
cookies forgés/tamponnés/rejoués.
Le DATA_DIR temporaire des tests est centralisé dans conftest.py pour éviter
les collisions entre modules de test.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@ -1,9 +1,23 @@
|
||||
"""Freezes calcul.py's notion of "today" for deterministic tests."""
|
||||
"""Fixtures partagées pour les tests.
|
||||
|
||||
Freeze "today" pour des calculs déterministes ET installe un DATA_DIR
|
||||
temporaire unique pour tout le process de test, afin que tous les modules
|
||||
de tests (test_logs, test_security, ...) partagent le même stockage.
|
||||
"""
|
||||
import datetime as dt_module
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import calcul
|
||||
import models
|
||||
|
||||
# ── DATA_DIR unique pour tout le run ─────────────────────────────────────────
|
||||
_TMP = Path(tempfile.mkdtemp())
|
||||
models.DATA_DIR = _TMP
|
||||
models.CONFIG_FILE = models.DATA_DIR / "config.json"
|
||||
models.CONFIG_FILE.write_text('{"heures_jour": 7.8, "allowed_email_domain": "x.fr"}')
|
||||
|
||||
|
||||
class FrozenDatetime(dt_module.datetime):
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
"""Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence."""
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Prépare un DATA_DIR temporaire AVANT l'import de models
|
||||
_TMP = Path(tempfile.mkdtemp())
|
||||
import models
|
||||
models.DATA_DIR = _TMP
|
||||
models.CONFIG_FILE = models.DATA_DIR / "config.json"
|
||||
models.CONFIG_FILE.write_text('{"heures_jour": 7.8, "allowed_email_domain": "x.fr"}')
|
||||
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
||||
import models # noqa: F401 (réexport pratique pour les tests ci-dessous)
|
||||
|
||||
# Recharge main dans un état propre
|
||||
sys.path.insert(0, ".")
|
||||
import main
|
||||
from fastapi.testclient import TestClient
|
||||
@ -19,14 +12,15 @@ client = TestClient(main.app)
|
||||
|
||||
|
||||
def _login_as(uid: str):
|
||||
"""Crée un user avec mdp puis se connecte (renvoie les cookies)."""
|
||||
"""Crée un user avec mdp puis se connecte (renvoie les cookies signés)."""
|
||||
import bcrypt
|
||||
models.save_auth(uid, email=f"{uid}@x.fr",
|
||||
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
||||
r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302, r.text
|
||||
return {"user_id": uid}
|
||||
# Récupère le cookie user_id réellement signé par le serveur
|
||||
return {"user_id": r.cookies.get("user_id")}
|
||||
|
||||
|
||||
COOKIES = _login_as("alice")
|
||||
|
||||
92
app/tests/test_security.py
Normal file
92
app/tests/test_security.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Tests de sécurité : anti-forgery du cookie de session.
|
||||
|
||||
Avant le correctif, n'importe qui pouvait forger le cookie `user_id=admin` et
|
||||
accéder aux données d'un autre utilisateur. La signature HMAC doit rendre toute
|
||||
altération détectable.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
||||
import models # noqa: F401
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
import main
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(main.app)
|
||||
|
||||
|
||||
def _seed_user(uid: str):
|
||||
import bcrypt
|
||||
models.save_auth(uid, email=f"{uid}@x.fr",
|
||||
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
||||
|
||||
|
||||
def _login(uid: str) -> dict:
|
||||
_seed_user(uid)
|
||||
r = client.post("/login/password",
|
||||
data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302, r.text
|
||||
return {"user_id": r.cookies.get("user_id")}
|
||||
|
||||
|
||||
# ── Forgery du cookie ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_unsigned_cookie_rejected():
|
||||
"""Un cookie `user_id=admin` forgé à la main doit être rejeté."""
|
||||
_seed_user("alice")
|
||||
r = client.get("/semaine/2026/29", cookies={"user_id": "alice"}, follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_tampered_signature_rejected():
|
||||
"""Un cookie valide dont on modifie la signature doit être rejeté."""
|
||||
cookies = _login("alice")
|
||||
raw = cookies["user_id"]
|
||||
user_id, _, sig = raw.rpartition(".")
|
||||
# Inverse un caractère de la signature
|
||||
bad_sig = ("0" if sig[0] != "0" else "1") + sig[1:]
|
||||
tampered = f"{user_id}.{bad_sig}"
|
||||
r = client.get("/semaine/2026/29", cookies={"user_id": tampered}, follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_valid_signed_cookie_accepted():
|
||||
"""Le cookie signé délivré par /login/password doit ouvrir la session."""
|
||||
cookies = _login("alice")
|
||||
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_signed_cookie_cannot_be_replayed_for_other_user():
|
||||
"""La signature d'alice ne doit pas valider pour bob (user_id modifié)."""
|
||||
cookies = _login("alice")
|
||||
raw = cookies["user_id"]
|
||||
_, _, sig = raw.rpartition(".")
|
||||
# Remplace alice par bob en conservant la signature d'alice
|
||||
forged = f"bob.{sig}"
|
||||
_seed_user("bob")
|
||||
r = client.get("/semaine/2026/29", cookies={"user_id": forged}, follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
# ── Helpers de signature ──────────────────────────────────────────────────────
|
||||
|
||||
def test_sign_and_verify_roundtrip():
|
||||
sig = main._sign_user_id("alice")
|
||||
assert main._verify_signed_cookie(sig) == "alice"
|
||||
|
||||
|
||||
def test_verify_rejects_malformed_inputs():
|
||||
for bad in ("", "alice", "alice.", ".", "alice.deadbeef", "inva/lid.xxx"):
|
||||
assert main._verify_signed_cookie(bad) is None
|
||||
|
||||
|
||||
def test_secret_is_stable_across_calls():
|
||||
s1 = models.load_or_create_secret()
|
||||
s2 = models.load_or_create_secret()
|
||||
assert s1 == s2
|
||||
Reference in New Issue
Block a user