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"