From 89e74e5ec7c608a47fa0a519eab85da121c50b1b Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 16 Sep 2026 08:14:00 +0200 Subject: [PATCH] =?UTF-8?q?Revue=20de=20s=C3=A9curit=C3=A9=20avant=20ouver?= =?UTF-8?q?ture=20au=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit de l'application avant sa première publication sur internet. Correctifs immédiats, peu coûteux et sans changement de comportement en dev : - Le backend refuse de démarrer en `ENVIRONMENT=production` si `JWT_SECRET` est vide, trop court ou égal à la valeur par défaut versionnée dans `.env.example` — sinon n'importe qui pourrait forger un jeton de session valide (y compris pour un compte COORDINATION) sans jamais s'authentifier. Testé (`test_config.py`). - Avertissement au démarrage si `EMAIL_ENABLED=false` en production : dans ce cas le jeton de lien magique est journalisé en clair (confort de dev, cf. `api/auth.py`) — un identifiant de connexion en clair dans les logs du conteneur en public serait un vrai problème. - `frontend/nginx.conf` : plus de journal d'accès (la route `/connexion?token=…` y ferait apparaître le jeton de lien magique en clair), en-têtes `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (empêche aussi la fuite de ce même jeton vers Google Fonts via l'en-tête Referer), `server_tokens off`. - `deploy/Caddyfile.benevoles` : mêmes en-têtes côté reverse proxy public, plus `Strict-Transport-Security`. - `deploy/README.md` : check-list sécurité avant ouverture (secret, SMTP, ne jamais mettre en production la base de démo `app.seed` — le mot de passe du compte coordination y est versionné en clair —, limites connues comme l'absence de rate-limiting sur `/auth/*`). Points vérifiés sans modification nécessaire : autorisation par rôle et cloisonnement par édition cohérents sur tous les endpoints (`owned_or_404` + vérification d'édition partout), mots de passe bcryptés, jetons de lien magique à usage unique haché en base (256 bits d'entropie), pas de PII/jeton dans les logs applicatifs (RGPD déjà en place), pas d'injection de gabarit dans les emails (substitution sur liste blanche, pas de `.format()`), pas d'injection d'en-tête SMTP (`EmailMessage` du socle Python), pas de `dangerouslySetInnerHTML` côté front, jeton JWT en `Authorization: Bearer` (pas de cookie ⇒ pas de CSRF), `.env` jamais versionné, dépendances à jour. Restent des choix à trancher avec l'utilisateur plutôt qu'à décider seul (compromis effort/risque) : rate-limiting sur les endpoints d'authentification, désactivation de `/docs` en production, conteneurs non-root. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- CLAUDE.md | 1 + backend/app/config.py | 30 ++++++++++++++++++++++++++- backend/app/main.py | 13 ++++++++++++ backend/tests/test_config.py | 40 ++++++++++++++++++++++++++++++++++++ deploy/Caddyfile.benevoles | 10 +++++++++ deploy/README.md | 27 +++++++++++++++++++++++- frontend/nginx.conf | 18 ++++++++++++++++ 7 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_config.py diff --git a/CLAUDE.md b/CLAUDE.md index 222fceb..9051b49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -521,6 +521,7 @@ Aucune dépendance locale requise hors Docker. | 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider | | 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider | | 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider | +| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/config.py b/backend/app/config.py index bb0f849..ea1dcc0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,7 +1,14 @@ from functools import lru_cache +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# Valeur de développement uniquement (versionnée dans `.env.example`) : un +# secret connu de quiconque lit le dépôt ne protège plus rien. Voir le +# contrôle dans `Settings.check_production_secrets`. +_DEV_JWT_SECRET = "dev-insecure-secret-change-me-please-32b" +_MIN_JWT_SECRET_LENGTH = 32 + class Settings(BaseSettings): """Configuration applicative, alimentée par l'environnement.""" @@ -20,7 +27,7 @@ class Settings(BaseSettings): ) # Authentification (lien magique + session JWT). Secret >= 32 octets. - jwt_secret: str = "dev-insecure-secret-change-me-please-32b" + jwt_secret: str = _DEV_JWT_SECRET magic_link_ttl_minutes: int = 30 session_ttl_hours: int = 12 @@ -49,6 +56,27 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.environment == "production" + @model_validator(mode="after") + def check_production_secrets(self) -> "Settings": + """Un secret JWT par défaut, vide ou trop court n'importe pas en dev + (confort), mais en production il permettrait de forger un jeton pour + n'importe quel compte (y compris COORDINATION) — donc de fabriquer + soi-même un jeton valide sans jamais s'authentifier. On refuse de + démarrer plutôt que de tourner silencieusement sans protection.""" + if not self.is_production: + return self + if ( + not self.jwt_secret + or self.jwt_secret == _DEV_JWT_SECRET + or len(self.jwt_secret) < _MIN_JWT_SECRET_LENGTH + ): + raise ValueError( + "JWT_SECRET manquant, par défaut ou trop court (>= " + f"{_MIN_JWT_SECRET_LENGTH} caractères) en production. " + "Générer une vraie valeur : openssl rand -hex 32" + ) + return self + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/main.py b/backend/app/main.py index 1ddb22e..894c976 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +import logging + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -23,6 +25,17 @@ configure_logging() settings = get_settings() +if settings.is_production and not settings.email_enabled: + # Avec EMAIL_ENABLED=false, le jeton de lien magique est journalisé en + # clair (confort de dev, voir `api/auth.py`) — acceptable en local, jamais + # en production : quiconque lit les logs du conteneur peut alors se + # connecter à la place de n'importe quel bénévole ou référent. + logging.getLogger("app.main").warning( + "EMAIL_ENABLED=false en production : les liens magiques sont " + "journalisés en clair au lieu d'être envoyés par email. Configurer " + "le SMTP avant l'ouverture au public." + ) + app = FastAPI(title="Gestion des bénévoles", version="0.1.0") app.add_middleware( diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..986b0c5 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,40 @@ +"""Garde-fou : un secret JWT par défaut/vide/trop court ne doit jamais tourner +en production (n'importe qui pourrait forger un jeton, y compris pour un +compte COORDINATION) — voir `app/config.py:check_production_secrets`.""" + +import pytest +from pydantic import ValidationError + +from app.config import Settings + +_OK_SECRET = "x" * 32 + + +def test_dev_secret_refused_in_production(): + with pytest.raises(ValidationError): + Settings( + environment="production", + jwt_secret="dev-insecure-secret-change-me-please-32b", + ) + + +def test_empty_secret_refused_in_production(): + with pytest.raises(ValidationError): + Settings(environment="production", jwt_secret="") + + +def test_short_secret_refused_in_production(): + with pytest.raises(ValidationError): + Settings(environment="production", jwt_secret="trop-court") + + +def test_proper_secret_accepted_in_production(): + settings = Settings(environment="production", jwt_secret=_OK_SECRET) + assert settings.jwt_secret == _OK_SECRET + + +def test_dev_secret_allowed_outside_production(): + # Confort de dev : le défaut versionné ne doit pas empêcher de démarrer + # en local ou en CI. + settings = Settings(environment="development") + assert settings.is_production is False diff --git a/deploy/Caddyfile.benevoles b/deploy/Caddyfile.benevoles index c1cf349..408acc9 100644 --- a/deploy/Caddyfile.benevoles +++ b/deploy/Caddyfile.benevoles @@ -9,6 +9,16 @@ benevoles.mon-domaine.fr { encode zstd gzip + # En-têtes de sécurité de base pour une appli publique. HSTS : Caddy gère + # le certificat et la redirection HTTP→HTTPS, mais n'ajoute pas ce header + # tout seul. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + } + # API : même préfixe `/api` que le proxy Vite en développement, retiré # avant de transmettre au backend (qui expose ses routes sous # `/editions/...`, pas `/api`). diff --git a/deploy/README.md b/deploy/README.md index 0b44340..0ddf1fd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,28 @@ serveur** (service système, hors de ce dépôt) reçoit le trafic public sur 443 et reverse-proxy vers les deux conteneurs, qui n'écoutent que sur `127.0.0.1`. +## Avant d'ouvrir au public — check-list sécurité + +- `JWT_SECRET` : une vraie valeur générée (`openssl rand -hex 32`), jamais la + valeur par défaut de `.env.example`. Le backend refuse de démarrer en + `ENVIRONMENT=production` sinon (`app/config.py:check_production_secrets`). +- `EMAIL_ENABLED=true` avec un SMTP qui fonctionne (`docker compose run --rm + backend uv run python -m app.email.check ` pour vérifier) — + sinon les liens magiques sont journalisés en clair au lieu d'être envoyés + (le backend avertit au démarrage tant que c'est le cas, voir plus bas). +- Édition créée via `python -m app.bootstrap` (étape 3), **jamais** via + `python -m app.seed` : ce dernier crée un compte de démonstration + (`coordination@lucioles.test`) avec un mot de passe versionné dans le + dépôt — un vrai identifiant de connexion COORDINATION public si la base de + démo devient la base de production. +- `CORS_ORIGINS` : sans objet ici tant que le frontend appelle l'API en + même origine via `/api/*` (voir le vhost Caddy, étape 5) — à ne renseigner + que si un autre client (domaine séparé, appli mobile) doit appeler l'API + directement. +- Aucun rate-limiting sur `/auth/login` ni `/auth/magic-link` à ce stade + (pas de verrouillage de compte, pas de throttling) — acceptable pour une + petite équipe connue, mais à surveiller si l'URL circule plus largement. + ## Étape 0 — pousser le dépôt sur GitLab ```bash @@ -54,7 +76,10 @@ PUBLIC_BASE_URL=https://benevoles.mon-domaine.fr # Emails (récapitulatifs de planning + liens de connexion). Laisser # EMAIL_ENABLED=false tant que le SMTP n'est pas prêt : les emails sont alors -# seulement journalisés. +# seulement journalisés — un jeton de lien magique en clair dans les logs du +# conteneur permet de se connecter à la place de n'importe qui, donc à +# repasser à true avant l'ouverture au public (le backend affiche un +# avertissement au démarrage tant que c'est le cas). EMAIL_ENABLED=true SMTP_HOST=smtp.mon-domaine.fr SMTP_PORT=587 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c1a6fed..b89c699 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,24 @@ server { root /usr/share/nginx/html; index index.html; + # Pas de version nginx dans les en-têtes/pages d'erreur (surface de + # reconnaissance en moins). + server_tokens off; + + # RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de + # connexion à usage unique) transitent par cette route. Pas de journal + # d'accès ici — un jeton en clair dans un log de conteneur serait un vrai + # moyen de se connecter à la place de quelqu'un pendant sa durée de vie. + access_log off; + + # Postures de base pour une appli publique : pas de sniffing de type MIME, + # jamais dans une frame étrangère (l'appli n'a aucune raison d'y être), + # et un Referer minimal vers les tiers (police Google Fonts) — l'URL + # complète (avec un éventuel ?token=) ne doit jamais leur être transmise. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # Fichiers hachés par Vite (contenu du nom = jamais modifiés une fois # publiés) : cache long côté navigateur. location /assets/ {