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:
Johan LEROY
2026-09-15 14:56:17 +02:00
parent 7fdd6513ca
commit e8f22bf427
9 changed files with 323 additions and 12 deletions
+35
View File
@@ -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
+23
View File
@@ -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")
+2 -1
View File
@@ -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")
+44
View File
@@ -1,8 +1,48 @@
# Pourquoi : `RedactingFilter` est la troisième ligne de défense, pas la première. La première
# est de ne jamais passer un secret au logger, la deuxième de ne jamais mettre un jeton dans
# une URL, que le journal d'accès enregistrerait de toute façon. Le filtre rattrape l'erreur
# que personne n'a relue, notamment l'écho SQL quand `debug` est actif.
import logging
import re
from logging.config import dictConfig
from typing import Final
from app.core.config import Settings
CAVIARDAGE: Final = "[expurgé]"
REMPLACEMENTS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
(re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{20,}=*"), f"Bearer {CAVIARDAGE}"),
(re.compile(r"eyJ[A-Za-z0-9._-]{20,}"), CAVIARDAGE),
(re.compile(r"\$argon2[a-z0-9]*\$\S+"), CAVIARDAGE),
(
re.compile(r'("?(?:password|mot_de_passe|secret|token)"?\s*[:=]\s*")[^"]*(")'),
rf"\1{CAVIARDAGE}\2",
),
(
re.compile(r"((?:password|mot_de_passe|secret|token)[A-Za-z_]*=)[^&\s;\"]+"),
rf"\1{CAVIARDAGE}",
),
(re.compile(r"(ev_refresh=)[^;\s]+"), rf"\1{CAVIARDAGE}"),
)
def redact(message: str) -> str:
for motif, remplacement in REMPLACEMENTS:
message = motif.sub(remplacement, message)
return message
class RedactingFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
expurge = redact(message)
if expurge != message:
record.msg = expurge
record.args = ()
return True
def configure_logging(settings: Settings) -> None:
formatter = "json" if settings.is_production else "console"
@@ -10,6 +50,9 @@ def configure_logging(settings: Settings) -> None:
{
"version": 1,
"disable_existing_loggers": False,
"filters": {
"redaction": {"()": "app.core.logging.RedactingFilter"},
},
"formatters": {
"console": {
"format": "%(asctime)s %(levelname)-8s %(name)s %(message)s",
@@ -23,6 +66,7 @@ def configure_logging(settings: Settings) -> None:
"default": {
"class": "logging.StreamHandler",
"formatter": formatter,
"filters": ["redaction"],
"stream": "ext://sys.stdout",
},
},
+28 -7
View File
@@ -1,11 +1,13 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator
from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware
from app.api.security import require_metrics_token
from app.api.v1.router import api_router
from app.core.config import Settings, get_settings
from app.core.logging import configure_logging, get_logger
@@ -13,6 +15,9 @@ from app.db.session import get_engine
logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
@@ -28,30 +33,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
resolved = settings or get_settings()
configure_logging(resolved)
documentee = resolved.api_docs_are_exposed
application = FastAPI(
title=resolved.name,
version=resolved.version,
debug=resolved.debug,
lifespan=lifespan,
docs_url=None if resolved.is_production else "/docs",
redoc_url=None if resolved.is_production else "/redoc",
openapi_url=None if resolved.is_production else "/openapi.json",
docs_url="/docs" if documentee else None,
redoc_url="/redoc" if documentee else None,
openapi_url="/openapi.json" if documentee else None,
)
application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins:
# Méthodes et en-têtes listés plutôt que joker : avec `allow_credentials`, la liste
# d'origines devient l'unique contrôle, autant documenter le contrat exact.
application.add_middleware(
CORSMiddleware,
allow_origins=resolved.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=METHODES_AUTORISEES,
allow_headers=EN_TETES_AUTORISES,
expose_headers=["Retry-After"],
max_age=600,
)
register_error_handlers(application)
Instrumentator().instrument(application).expose(
application, endpoint="/metrics", include_in_schema=False
application,
endpoint="/metrics",
include_in_schema=False,
dependencies=[Depends(require_metrics_token)],
)
application.include_router(api_router, prefix=resolved.api_prefix)
# Piège : sans cette surcharge, une configuration passée à `create_app()` ne piloterait
# que la construction, et les dépendances continueraient de lire `get_settings()` depuis
# l'environnement. Un test « en production » ne testerait alors pas la production.
if settings is not None:
application.dependency_overrides[get_settings] = lambda: resolved
return application
+4 -1
View File
@@ -10,7 +10,10 @@ class LivenessStatus(BaseModel):
environment: str
# Contrainte : la sonde ne publie pas la version de TimescaleDB. Une version exacte de
# composant, servie sans authentification, est de la reconnaissance gratuite pour qui
# cherche une CVE. Elle part dans le journal, où elle sert au diagnostic.
class ReadinessStatus(BaseModel):
status: Literal["ready"]
database: Literal["reachable"]
timescaledb: str
timescaledb: Literal["loaded"]