feat(backend): durcit la surface exposée
En-têtes de sécurité, CORS resserré, caviardage des journaux, `/metrics` derrière un jeton facultatif, documentation fermée en préproduction, et la sonde de disponibilité cesse de publier la version de TimescaleDB. HSTS et CSP sont volontairement absents : l'application ignore si TLS termine devant elle, et une CSP sur une API JSON ne protège presque rien. Les deux appartiennent au terminateur TLS, celle qui compte protège la page Angular. `/metrics` est gardé par un jeton statique et non par un rôle : coupler la supervision au modèle d'utilisateurs casserait la collecte à chaque panne d'authentification, c'est-à-dire quand on en a le plus besoin. Le contrôle principal reste le réseau. Le caviardage est la troisième ligne de défense, pas la première. On ne passe aucun secret au logger et aucun jeton dans une URL ; le filtre rattrape ce que personne n'a relu, à commencer par l'écho SQL qui publiait les empreintes Argon2 quand `debug` est actif. Corrige un défaut que le test a révélé : `create_app(settings)` ne pilotait que la construction, les dépendances continuaient de lire `get_settings()` depuis l'environnement. Un test « en production » ne testait donc pas la production, et `TESTING.md` promet le contraire.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# Pourquoi : `SecurityHeadersMiddleware` ne pose ni HSTS ni CSP, et c'est délibéré.
|
||||
# L'application ignore si TLS termine devant elle, donc elle ne peut pas décider d'un HSTS ;
|
||||
# et une CSP sur une API JSON ne protège presque rien, celle qui compte protège la page
|
||||
# Angular. Les deux appartiennent au terminateur TLS.
|
||||
# Contrainte : `/docs` charge Swagger depuis un CDN, une CSP stricte ici casserait la
|
||||
# documentation sans rien sécuriser.
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Final
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
EN_TETES: Final[dict[str, str]] = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
}
|
||||
|
||||
PREFIXE_AUTHENTIFICATION: Final = "/auth"
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
response = await call_next(request)
|
||||
for nom, valeur in EN_TETES.items():
|
||||
response.headers.setdefault(nom, valeur)
|
||||
|
||||
# Une réponse d'authentification ne doit jamais être conservée par un intermédiaire.
|
||||
if PREFIXE_AUTHENTIFICATION in request.url.path:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -0,0 +1,23 @@
|
||||
# Pourquoi : `/metrics` est protégé par un jeton statique et non par un rôle applicatif. Coupler
|
||||
# la supervision au modèle d'utilisateurs casserait la collecte à chaque panne
|
||||
# d'authentification, c'est-à-dire précisément quand on a besoin des métriques. Le vrai contrôle
|
||||
# reste le réseau : Prometheus scrute sur le réseau interne et `/metrics` ne sort pas.
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.api.deps import SettingsDep
|
||||
|
||||
|
||||
def require_metrics_token(request: Request, settings: SettingsDep) -> None:
|
||||
attendu = settings.metrics_token
|
||||
if attendu is None:
|
||||
return
|
||||
|
||||
presente = request.headers.get("authorization", "")
|
||||
prefixe = "Bearer "
|
||||
if not presente.startswith(prefixe) or not secrets.compare_digest(
|
||||
presente[len(prefixe) :], attendu.get_secret_value()
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Jeton requis")
|
||||
@@ -40,4 +40,5 @@ async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||
detail="Extension TimescaleDB absente",
|
||||
)
|
||||
|
||||
return ReadinessStatus(status="ready", database="reachable", timescaledb=version)
|
||||
logger.debug("Extension TimescaleDB en version %s", version)
|
||||
return ReadinessStatus(status="ready", database="reachable", timescaledb="loaded")
|
||||
|
||||
Reference in New Issue
Block a user