Ajoute les en-têtes de sécurité et le drapeau Secure sur le cookie
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
This commit is contained in:
43
app/main.py
43
app/main.py
@ -73,7 +73,29 @@ async def csrf_origin_check(request: Request, call_next):
|
|||||||
origin, request.headers.get("host"), request.url.path,
|
origin, request.headers.get("host"), request.url.path,
|
||||||
)
|
)
|
||||||
return JSONResponse({"error": "csrf"}, status_code=403)
|
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")
|
@app.on_event("startup")
|
||||||
@ -139,11 +161,22 @@ def _get_user(request: Request) -> str | None:
|
|||||||
return _verify_signed_cookie(request.cookies.get("user_id", ""))
|
return _verify_signed_cookie(request.cookies.get("user_id", ""))
|
||||||
|
|
||||||
|
|
||||||
def _set_auth_cookie(resp: Response, user_id: str) -> None:
|
def _set_auth_cookie(resp: Response, user_id: str, request: Request | None = None) -> None:
|
||||||
"""Set the signed, HttpOnly, SameSite session cookie."""
|
"""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(
|
resp.set_cookie(
|
||||||
"user_id", _sign_user_id(user_id),
|
"user_id", _sign_user_id(user_id),
|
||||||
httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30,
|
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)
|
logger.info("connexion réussie: user_id=%r", user_id)
|
||||||
resp = RedirectResponse("/", status_code=302)
|
resp = RedirectResponse("/", status_code=302)
|
||||||
_set_auth_cookie(resp, user_id)
|
_set_auth_cookie(resp, user_id, request)
|
||||||
return resp
|
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)
|
logger.info("mot de passe défini pour %r", user_id)
|
||||||
|
|
||||||
resp = RedirectResponse("/", status_code=302)
|
resp = RedirectResponse("/", status_code=302)
|
||||||
_set_auth_cookie(resp, user_id)
|
_set_auth_cookie(resp, user_id, request)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -421,3 +421,80 @@ def test_presence_endpoints_require_post():
|
|||||||
assert r_post_arr.status_code == 200
|
assert r_post_arr.status_code == 200
|
||||||
assert r_post_dep.status_code == 200
|
assert r_post_dep.status_code == 200
|
||||||
assert r_post_arr.json() == {"ok": True}
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user