diff --git a/Makefile b/Makefile index 763e5f5..5be293d 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ AIRFLOW := etl/airflow COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml # Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose. -# Le `ifdef` évite d'exporter une valeur vide, qui masquerait alors celle du fichier `.env`. -ifdef PUBLIC_HOST +# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`. +PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1) +PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local) export PUBLIC_HOST -endif ifdef ACME_EMAIL export ACME_EMAIL endif @@ -119,11 +119,13 @@ docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser - PUBLIC_HOST=$${PUBLIC_HOST:-enervision.local} ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) + ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) -stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... requis +stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin @test -f infra/proxy/tls/fullchain.pem \ || { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; } + @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \ + || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; } $(COMPOSE_PROD) up -d --build stack-down: ## Arrête la stack complète en conservant les données @@ -132,9 +134,11 @@ stack-down: ## Arrête la stack complète en conservant les données stack-logs: ## Suit les journaux du reverse proxy $(COMPOSE_PROD) logs -f proxy -tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST et ACME_EMAIL requis +tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis + @test "$(PUBLIC_HOST)" != enervision.local \ + || { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; } $(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \ - -d $${PUBLIC_HOST:?PUBLIC_HOST=... requis} \ + -d $(PUBLIC_HOST) \ --email $${ACME_EMAIL:?ACME_EMAIL=... requis} \ --agree-tos --no-eff-email --deploy-hook /deploy-hook.sh $(COMPOSE_PROD) exec proxy nginx -s reload diff --git a/README.md b/README.md index 7e1181e..e560c94 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ curl -s localhost:8000/api/v1/health/ready ## Stack complète derrière le reverse proxy -Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine : +Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine. +L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** : ```bash make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 981779f..8e508c2 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -34,6 +34,11 @@ }, "configurations": { "production": { + "optimization": { + "styles": { + "inlineCritical": false + } + }, "budgets": [ { "type": "initial", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4f2d425..868ae17 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,6 +3,8 @@ # rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide. # Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul # moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy. +# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour +# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi. name: enervision @@ -35,8 +37,8 @@ services: APP_ENV: prod APP_DEBUG: "false" APP_TRUST_PROXY_HEADERS: "true" - APP_CORS_ORIGINS: https://${PUBLIC_HOST:?PUBLIC_HOST est requis pour la stack complète} - APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST}/reset-password + APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local} + APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password frontend: ports: !reset null @@ -44,8 +46,10 @@ services: proxy: image: nginx:1.28-alpine depends_on: - - backend - - frontend + backend: + condition: service_healthy + frontend: + condition: service_started ports: - "80:80" - "443:443" @@ -57,7 +61,7 @@ services: restart: unless-stopped certbot: - image: certbot/certbot + image: certbot/certbot:v5.8.0 profiles: ["acme"] volumes: - letsencrypt:/etc/letsencrypt diff --git a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md index 1d5eab4..014f95e 100644 --- a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md +++ b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md @@ -73,8 +73,30 @@ Swagger, il publiera les métriques avec. `$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP du proxy, c'est-à-dire globalement. +- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire + `request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de + `request.client` du backend, en dernier recours quand l'en-tête est absent. - Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20 - requêtes par seconde sur l'API, 30 par minute sur `/api/v1/auth/`. + requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret, + `login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en + sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule + adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration. +- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires + d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela : + ``. La feuille serait restée en + `media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans + `angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de + composants injectés à l'exécution. +- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient + donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être + amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique + couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`. +- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle + sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe + donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent + couvre l'hôte demandé, ce qui est la condition réelle à tenir. +- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend + sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502. - La ligne API8 transport de `owasp-traceabilite.md` se referme. - **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index ee0b6f8..14a7012 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -134,6 +134,13 @@ Compose. `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le rafraîchissement sur 401. Détail complet dans [31-contrat-authentification.md](31-contrat-authentification.md). +- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les + gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un + (``), ce qui aurait laissé l'application sans + style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration + de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier + affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à + l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire. ## Tests diff --git a/infra/proxy/README.md b/infra/proxy/README.md index cb33d5f..558e8b9 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -12,15 +12,22 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la configuration est montée en volume par `docker-compose.prod.yml`. +L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4 +ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports. + ## Routage | Chemin | Destination | Remarque | |---|---|---| | `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS | -| `/api/v1/auth/` | `backend:8000` | Limitation de débit resserrée, 30 requêtes par minute | -| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel | +| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute | +| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde | | `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues | +La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh` +partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une +seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal. + L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le proxy demanderait une authentification propre, qui n'est pas la leur. @@ -68,6 +75,8 @@ greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ## Vérifier la configuration sans démarrer la stack +`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin. + ```bash docker run --rm \ -v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \ diff --git a/infra/proxy/conf.d/enervision.conf b/infra/proxy/conf.d/enervision.conf index cbadfaa..d3f067e 100644 --- a/infra/proxy/conf.d/enervision.conf +++ b/infra/proxy/conf.d/enervision.conf @@ -5,6 +5,8 @@ # Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de # nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la # variable et le résolveur interne de Docker : la résolution redevient dynamique. +# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de +# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007. server { listen 80 default_server; @@ -46,7 +48,9 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 60s; - location /api/v1/auth/ { + # Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de + # l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page. + location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ { limit_req zone=auth burst=20 nodelay; set $cible_api http://backend:8000; proxy_pass $cible_api$request_uri;