Spaces:
Running
Running
File size: 8,434 Bytes
c9d381c de9192c c9d381c de9192c c9d381c de9192c c9d381c de9192c c9d381c de9192c c9d381c de9192c c9d381c de9192c c9d381c de9192c c9d381c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Tests Sprint A4 — protection CSRF (item B-11 de l'audit).
Couvre les deux modes de l'application :
- **Mode public** (``PICARONES_CSRF_REQUIRED`` non défini) : le
middleware est no-op, tous les POST passent sans token. C'est le
régime du HuggingFace Space où il n'y a pas de session
authentifiée à protéger.
- **Mode institutionnel** (``PICARONES_CSRF_REQUIRED=1``) : tout
POST/PUT/DELETE/PATCH non exempt exige un token cookie + header
signé HMAC. Régime cible BnF derrière SSO.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def public_client(monkeypatch) -> TestClient:
"""Client en mode public (CSRF désactivé)."""
monkeypatch.delenv("PICARONES_CSRF_REQUIRED", raising=False)
# On ré-importe l'app pour que le middleware lise la variable à
# chaque requête (il le fait déjà via ``is_csrf_required()`` à chaque
# appel — pas besoin de reload du module).
from picarones.interfaces.web.app import app
return TestClient(app)
@pytest.fixture
def institutional_client(monkeypatch) -> TestClient:
"""Client en mode institutionnel (CSRF activé)."""
monkeypatch.setenv("PICARONES_CSRF_REQUIRED", "1")
monkeypatch.setenv("PICARONES_CSRF_SECRET", "test-secret-do-not-use-in-prod" * 2)
from picarones.interfaces.web.app import app
return TestClient(app)
# ---------------------------------------------------------------------------
# Mode public — bypass complet
# ---------------------------------------------------------------------------
def test_public_mode_post_succeeds_without_token(public_client: TestClient) -> None:
"""En mode public, un POST sans token CSRF doit passer normalement."""
# ``/api/lang/{lang_code}`` est un POST simple sans dépendance lourde
r = public_client.post("/api/lang/fr")
assert r.status_code == 200, r.text
def test_public_mode_csrf_endpoint_returns_disabled(public_client: TestClient) -> None:
"""``/api/csrf/token`` doit signaler ``enabled=false`` en mode public."""
r = public_client.get("/api/csrf/token")
assert r.status_code == 200
body = r.json()
assert body["enabled"] is False
assert body["token"] is None
# ---------------------------------------------------------------------------
# Mode institutionnel — protection active
# ---------------------------------------------------------------------------
def test_institutional_post_without_token_returns_403(
institutional_client: TestClient,
) -> None:
"""En mode institutionnel, un POST sans token doit renvoyer 403."""
r = institutional_client.post("/api/lang/fr")
assert r.status_code == 403
assert "CSRF" in r.json()["detail"]
def test_institutional_post_with_only_cookie_returns_403(
institutional_client: TestClient,
) -> None:
"""Cookie présent mais header absent → 403 (le pattern double-submit
exige les deux)."""
# 1. Récupérer un token via /api/csrf/token (qui pose le cookie)
r = institutional_client.get("/api/csrf/token")
token = r.json()["token"]
# 2. POST avec le cookie automatique mais sans header
r2 = institutional_client.post("/api/lang/fr")
assert r2.status_code == 403
assert token # sanity : le token a bien été retourné
def test_institutional_post_with_only_header_returns_403(
institutional_client: TestClient,
) -> None:
"""Header présent mais cookie absent → 403."""
# On force l'absence de cookie en utilisant un nouveau client sans état
from picarones.interfaces.web.app import app
fresh = TestClient(app)
r = fresh.post(
"/api/lang/fr",
headers={"X-CSRF-Token": "deadbeef.cafebabe"},
)
assert r.status_code == 403
def test_institutional_post_with_valid_token_succeeds(
institutional_client: TestClient,
) -> None:
"""Token cookie + header identiques et signature valide → 200."""
r = institutional_client.get("/api/csrf/token")
token = r.json()["token"]
assert token is not None
# Le cookie est posé automatiquement par TestClient ; on injecte
# le même token dans le header.
r2 = institutional_client.post(
"/api/lang/fr",
headers={"X-CSRF-Token": token},
)
assert r2.status_code == 200, r2.text
def test_institutional_post_with_mismatched_token_returns_403(
institutional_client: TestClient,
) -> None:
"""Cookie et header différents → 403 (anti-CSRF en double-submit)."""
institutional_client.get("/api/csrf/token") # pose le cookie
r = institutional_client.post(
"/api/lang/fr",
headers={"X-CSRF-Token": "0011223344556677.aabbccddeeff0011"},
)
assert r.status_code == 403
def test_institutional_post_with_forged_signature_returns_403(
institutional_client: TestClient,
monkeypatch,
) -> None:
"""Token au bon format mais signature non-HMAC valide → 403."""
# On forge un token cookie+header identiques mais signé avec un
# secret bidon — le middleware doit le rejeter.
forged = "deadbeefcafebabe1234567890abcdef.000102030405060708090a0b0c0d0e0f"
institutional_client.cookies.set("picarones_csrf", forged)
r = institutional_client.post(
"/api/lang/fr",
headers={"X-CSRF-Token": forged},
)
assert r.status_code == 403
# ---------------------------------------------------------------------------
# Endpoints exemptés
# ---------------------------------------------------------------------------
def test_health_is_csrf_exempt(institutional_client: TestClient) -> None:
"""``/health`` doit rester accessible sans token même en mode CSRF."""
r = institutional_client.get("/health")
assert r.status_code == 200
def test_csrf_token_endpoint_does_not_require_token(
institutional_client: TestClient,
) -> None:
"""``/api/csrf/token`` lui-même ne doit pas exiger un token (sinon
impossible de bootstraper)."""
# Le endpoint est en GET donc CSRF ne s'applique pas, mais on
# vérifie aussi qu'il est dans la liste des exemptions (au cas où
# un PR futur le passerait en POST).
from picarones.interfaces.web.security import CSRF_EXEMPT_PATH_PREFIXES
assert any(
"/api/csrf/token".startswith(p) for p in CSRF_EXEMPT_PATH_PREFIXES
)
# ---------------------------------------------------------------------------
# Helpers internes
# ---------------------------------------------------------------------------
def test_generate_then_verify_token_round_trip(monkeypatch) -> None:
"""``generate_csrf_token`` produit un token que ``verify_csrf_token``
valide. Garantit le round-trip de signature."""
monkeypatch.setenv("PICARONES_CSRF_SECRET", "round-trip-secret-32-bytes-ok!")
from picarones.interfaces.web.security import generate_csrf_token, verify_csrf_token
token = generate_csrf_token()
assert verify_csrf_token(token) is True
def test_verify_token_rejects_garbage(monkeypatch) -> None:
monkeypatch.setenv("PICARONES_CSRF_SECRET", "round-trip-secret-32-bytes-ok!")
from picarones.interfaces.web.security import verify_csrf_token
assert verify_csrf_token(None) is False
assert verify_csrf_token("") is False
assert verify_csrf_token("no-dot") is False
assert verify_csrf_token("bad.hex") is False
assert verify_csrf_token("deadbeef.deadbeef") is False # ok format, signature wrong
def test_csrf_disabled_by_default(monkeypatch) -> None:
"""Garantit qu'on a bien posé la rétrocompat HuggingFace : sans la
variable d'env, ``is_csrf_required()`` retourne False."""
monkeypatch.delenv("PICARONES_CSRF_REQUIRED", raising=False)
from picarones.interfaces.web.security import is_csrf_required
assert is_csrf_required() is False
@pytest.mark.parametrize("value,expected", [
("1", True),
("true", True),
("yes", True),
# NB : pattern aligné avec ``is_public_mode()`` — sensible à la casse
# (convention pré-existante). ``TRUE`` n'est pas accepté par design.
("0", False),
("false", False),
("", False),
("anything", False),
("TRUE", False), # documenté : majuscule rejetée
])
def test_csrf_env_var_parsing(monkeypatch, value: str, expected: bool) -> None:
monkeypatch.setenv("PICARONES_CSRF_REQUIRED", value)
from picarones.interfaces.web.security import is_csrf_required
assert is_csrf_required() is expected
|