From ca99ca85a233cf7d6b7859aedee2409d756384e6 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Mon, 20 Jul 2026 00:35:15 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20les=20en-t=C3=AAtes=20de=20s=C3=A9curi?= =?UTF-8?q?t=C3=A9=20et=20le=20drapeau=20Secure=20sur=20le=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cookie de session était émis sans Secure : en cas de downgrade HTTP ou de mixed content, il aurait transité en clair. Aucun en-tête de sécurité n'était positionné (pas de CSP, pas de X-Frame-Options, pas de HSTS), ce qui laissait le champ libre au clickjacking, aux sniffing MIME et aux fuites via Referer. Changements : 1. _set_auth_cookie accepte désormais la request : le drapeau Secure est posé automatiquement si scheme == 'https', ou si la nouvelle clé config.force_secure_cookies est vraie. En HTTP local (dev/test), le cookie reste sans Secure pour ne pas casser les navigateurs. 2. Le middleware CSRF injecte maintenant les en-têtes de sécurité sur toutes les réponses : - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - Referrer-Policy: same-origin - Content-Security-Policy (default-src 'self', frame-ancestors 'none', base-uri 'self', avec autorisation pour Google Fonts et unsafe-inline pour les JS/CSS inline des templates semaine/stats ; à durcir plus tard) - Strict-Transport-Security (HTTPS uniquement) Les nouveaux tests vérifient la présence des headers, le HSTS en HTTPS uniquement, le Secure en HTTPS, l'absence de Secure en HTTP, et l'override par force_secure_cookies. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/main.py | 43 ++++++++++++++++++--- app/tests/test_security.py | 77 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 41f103a..f0744b8 100644 --- a/app/main.py +++ b/app/main.py @@ -73,7 +73,29 @@ async def csrf_origin_check(request: Request, call_next): origin, request.headers.get("host"), request.url.path, ) return JSONResponse({"error": "csrf"}, status_code=403) - return await call_next(request) + response = await call_next(request) + # ── En-têtes de sécurité appliqués à toutes les réponses ── + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "same-origin" + # CSP relaxée : on autorise Google Fonts (chargé depuis base.html) et les + # scripts/styles inline (semaine.html et stats.html ont du JS/CSS inline). + # 'unsafe-inline' reste nécessaire tant que ces ressources ne sont pas + # extraites dans des fichiers statiques. + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'" + ) + # HSTS uniquement en HTTPS (sinon on risquerait de bloquer un user en HTTP) + if request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response @app.on_event("startup") @@ -139,11 +161,22 @@ def _get_user(request: Request) -> str | None: return _verify_signed_cookie(request.cookies.get("user_id", "")) -def _set_auth_cookie(resp: Response, user_id: str) -> None: - """Set the signed, HttpOnly, SameSite session cookie.""" +def _set_auth_cookie(resp: Response, user_id: str, request: Request | None = None) -> None: + """Set the signed, HttpOnly, SameSite session cookie. + + Le drapeau `secure` est positionné automatiquement si la requête courante + est en HTTPS, ou si config.force_secure_cookies est vrai. Cela évite que + le cookie ne transite en clair sur un downgrade HTTP. + """ + secure = False + if request is not None and request.url.scheme == "https": + secure = True + if load_config().get("force_secure_cookies"): + secure = True resp.set_cookie( "user_id", _sign_user_id(user_id), httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30, + secure=secure, ) @@ -462,7 +495,7 @@ def login_password(request: Request, email: str = Form(""), password: str = Form logger.info("connexion réussie: user_id=%r", user_id) resp = RedirectResponse("/", status_code=302) - _set_auth_cookie(resp, user_id) + _set_auth_cookie(resp, user_id, request) return resp @@ -504,7 +537,7 @@ def set_password_submit(request: Request, token: str, password: str = Form(""), logger.info("mot de passe défini pour %r", user_id) resp = RedirectResponse("/", status_code=302) - _set_auth_cookie(resp, user_id) + _set_auth_cookie(resp, user_id, request) return resp diff --git a/app/tests/test_security.py b/app/tests/test_security.py index b856ef9..31f0c71 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -421,3 +421,80 @@ def test_presence_endpoints_require_post(): assert r_post_arr.status_code == 200 assert r_post_dep.status_code == 200 assert r_post_arr.json() == {"ok": True} + + +# ── En-têtes de sécurité + cookie Secure ────────────────────────────────────── + +def test_security_headers_present_on_every_response(): + """Les en-têtes X-Content-Type-Options, X-Frame-Options, CSP et Referrer-Policy + doivent être positionnés sur toutes les réponses.""" + r = client.get("/login") + assert r.headers.get("X-Content-Type-Options") == "nosniff" + assert r.headers.get("X-Frame-Options") == "DENY" + assert r.headers.get("Referrer-Policy") == "same-origin" + csp = r.headers.get("Content-Security-Policy", "") + assert "default-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + assert "base-uri 'self'" in csp + + +def test_hsts_header_only_on_https(): + """HSTS ne doit être positionné qu'en HTTPS.""" + # En HTTP (TestClient par défaut), pas de HSTS + r = client.get("/login") + assert "Strict-Transport-Security" not in r.headers + + # En HTTPS, HSTS doit être présent + https_client = TestClient(main.app, base_url="https://testserver") + r2 = https_client.get("/login") + assert r2.headers.get("Strict-Transport-Security", "").startswith("max-age=31536000") + + +def test_cookie_has_secure_flag_over_https(): + """Sur une requête HTTPS, le cookie de session doit porter le drapeau Secure.""" + https_client = TestClient(main.app, base_url="https://testserver") + _seed_user("kate") + r = https_client.post( + "/login/password", + data={"email": "kate@x.fr", "password": "x" * 12}, + follow_redirects=False, + ) + assert r.status_code == 302 + cookie = r.headers.get("set-cookie", "") + assert "Secure" in cookie, cookie + assert "HttpOnly" in cookie + assert "SameSite=lax" in cookie + + +def test_cookie_omits_secure_flag_over_http(): + """En HTTP (dev local), le cookie ne doit pas avoir Secure (sinon il ne serait pas posé).""" + _seed_user("liam") + r = client.post( + "/login/password", + data={"email": "liam@x.fr", "password": "x" * 12}, + follow_redirects=False, + ) + assert r.status_code == 302 + cookie = r.headers.get("set-cookie", "") + assert "HttpOnly" in cookie + assert "SameSite=lax" in cookie + # En HTTP, pas de Secure (sinon le navigateur refuserait le cookie) + assert "Secure" not in cookie + + +def test_force_secure_cookies_config_overrides_scheme(): + """config.force_secure_cookies doit forcer Secure même en HTTP.""" + cfg = models.load_config() + cfg["force_secure_cookies"] = True + models.save_config(cfg) + try: + _seed_user("mona") + r = client.post( + "/login/password", + data={"email": "mona@x.fr", "password": "x" * 12}, + follow_redirects=False, + ) + assert "Secure" in r.headers.get("set-cookie", "") + finally: + cfg["force_secure_cookies"] = False + models.save_config(cfg)