From e8f22bf42796d0e38ac617087955b761953998d0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:56:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20durcit=20la=20surface=20expos?= =?UTF-8?q?=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/backend/app/api/middleware.py | 35 ++++++ apps/backend/app/api/security.py | 23 ++++ apps/backend/app/api/v1/endpoints/health.py | 3 +- apps/backend/app/core/logging.py | 44 ++++++++ apps/backend/app/main.py | 35 ++++-- apps/backend/app/schemas/health.py | 5 +- apps/backend/tests/api/test_hardening.py | 111 ++++++++++++++++++++ apps/backend/tests/api/test_health.py | 7 +- apps/backend/tests/core/test_logging.py | 72 +++++++++++++ 9 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 apps/backend/app/api/middleware.py create mode 100644 apps/backend/app/api/security.py create mode 100644 apps/backend/tests/api/test_hardening.py create mode 100644 apps/backend/tests/core/test_logging.py diff --git a/apps/backend/app/api/middleware.py b/apps/backend/app/api/middleware.py new file mode 100644 index 0000000..0a01192 --- /dev/null +++ b/apps/backend/app/api/middleware.py @@ -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 diff --git a/apps/backend/app/api/security.py b/apps/backend/app/api/security.py new file mode 100644 index 0000000..6b47646 --- /dev/null +++ b/apps/backend/app/api/security.py @@ -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") diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index f97caaf..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -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") diff --git a/apps/backend/app/core/logging.py b/apps/backend/app/core/logging.py index c0cc9a6..14cac3e 100644 --- a/apps/backend/app/core/logging.py +++ b/apps/backend/app/core/logging.py @@ -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", }, }, diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 2ddf1dd..6c3c866 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -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 diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py index e4ec86e..e7ddd4c 100644 --- a/apps/backend/app/schemas/health.py +++ b/apps/backend/app/schemas/health.py @@ -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"] diff --git a/apps/backend/tests/api/test_hardening.py b/apps/backend/tests/api/test_hardening.py new file mode 100644 index 0000000..3ee1170 --- /dev/null +++ b/apps/backend/tests/api/test_hardening.py @@ -0,0 +1,111 @@ +import pytest +from httpx import ASGITransport, AsyncClient +from httpx import Response as HttpResponse + +from app.main import create_app +from tests.factories import make_settings + +ORIGINE = "https://enervision.fr" + + +async def interroge( + settings_overrides: dict[str, object], chemin: str, **kwargs: object +) -> HttpResponse: + application = create_app(make_settings(**settings_overrides)) + transport = ASGITransport(app=application) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get(chemin, **kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("entete", "valeur"), + [ + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("referrer-policy", "no-referrer"), + ], + ids=["nosniff", "anti_iframe", "referrer"], +) +async def test_every_response_carries_the_security_headers( + client: AsyncClient, entete: str, valeur: str +) -> None: + response = await client.get("/api/v1/health/live") + + assert response.headers[entete] == valeur + + +async def test_the_application_never_sets_hsts_itself(client: AsyncClient) -> None: + response = await client.get("/api/v1/health/live") + + assert "strict-transport-security" not in response.headers + + +@pytest.mark.parametrize( + "env", + ["staging", "prod"], + ids=["preproduction", "production"], +) +async def test_the_documentation_disappears_outside_development(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + for chemin in ("/docs", "/openapi.json"): + assert (await interroge(surcharges, chemin)).status_code == 404 + + +@pytest.mark.parametrize("env", ["local", "dev"], ids=["local", "developpement"]) +async def test_the_documentation_stays_available_while_developing(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_an_explicit_override_can_reopen_the_documentation() -> None: + surcharges = {"env": "prod", "cors_origins": ORIGINE, "expose_api_docs": True} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient) -> None: + response = await client.get("/metrics") + + assert response.status_code == 200 + + +async def test_metrics_demand_the_token_once_one_is_configured() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + + assert (await interroge(surcharges, "/metrics")).status_code == 401 + + +async def test_metrics_answer_to_the_right_token() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-long"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 200 + + +async def test_metrics_refuse_a_token_that_is_almost_right() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-lon"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 401 + + +async def test_an_unhandled_error_returns_a_correlation_id_and_no_traceback() -> None: + application = create_app(make_settings()) + + @application.get("/api/v1/essai-panne") + async def _casse() -> None: + raise RuntimeError("secret interne de la pile") + + transport = ASGITransport(app=application, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/essai-panne") + + assert response.status_code == 500 + assert "secret interne de la pile" not in response.text + assert response.json()["correlation"] diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index d5ba9bc..b9f2f33 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -17,7 +17,7 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } -async def test_readiness_reports_the_timescaledb_version( +async def test_readiness_confirms_the_extension_without_leaking_its_version( fake_session: Callable[..., None], client: AsyncClient ) -> None: fake_session(result="2.22.1") @@ -28,8 +28,9 @@ async def test_readiness_reports_the_timescaledb_version( assert response.json() == { "status": "ready", "database": "reachable", - "timescaledb": "2.22.1", + "timescaledb": "loaded", } + assert "2.22.1" not in response.text async def test_readiness_returns_503_when_the_extension_is_missing( @@ -75,4 +76,4 @@ async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None: body = response.json() assert body["status"] == "ready" assert body["database"] == "reachable" - assert body["timescaledb"] + assert body["timescaledb"] == "loaded" diff --git a/apps/backend/tests/core/test_logging.py b/apps/backend/tests/core/test_logging.py new file mode 100644 index 0000000..191c0c8 --- /dev/null +++ b/apps/backend/tests/core/test_logging.py @@ -0,0 +1,72 @@ +import logging + +import pytest + +from app.core.logging import CAVIARDAGE, RedactingFilter, redact + + +@pytest.mark.parametrize( + "message", + [ + "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.charge-utile-assez-longue.signature", + "jeton brut eyJhbGciOiJIUzI1NiJ9abcdefghijklmnopqrstuvwxyz", + "INSERT ... ('$argon2id$v=19$m=19456,t=2,p=1$sel-en-clair$empreinte-en-clair')", + '{"password": "le-mot-de-passe-du-client"}', + "current_password=le-mot-de-passe", + "Cookie: ev_refresh=abcdefghijklmnopqrstuvwxyz0123456789", + ], + ids=[ + "en_tete_bearer", + "jeton_jwt_nu", + "empreinte_argon2", + "mot_de_passe_json", + "mot_de_passe_en_paire", + "cookie_de_rafraichissement", + ], +) +def test_redact_removes_every_known_secret_shape(message: str) -> None: + expurge = redact(message) + + assert CAVIARDAGE in expurge + for suspect in ("le-mot-de-passe", "empreinte-en-clair", "abcdefghijklmnopqrstuvwxyz"): + assert suspect not in expurge + + +def test_redact_leaves_an_innocent_message_untouched() -> None: + message = "auth.login.success user_id=3f2a ip=203.0.113.10" + + assert redact(message) == message + + +def test_the_filter_rewrites_the_record_before_it_reaches_the_handler() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='requete {"password": "%s"}', + args=("secret-du-client",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert "secret-du-client" not in enregistrement.getMessage() + + +def test_the_filter_keeps_a_record_that_holds_no_secret() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="requete %s", + args=("/api/v1/health/live",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert enregistrement.getMessage() == "requete /api/v1/health/live"