From bc755286169c042def0113eec71d8d27e08b310d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 12:11:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(infra):=20l=C3=A8ve=20les=20points=20de=20r?= =?UTF-8?q?evue=20du=20reverse=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose interpole tout le fichier avant n'importe quelle sous-commande : la garde `${PUBLIC_HOST:?}` de l'overlay cassait `stack-down` et `stack-logs` autant que le démarrage. La valeur retombe sur `enervision.local`, et `stack-up` vérifie à la place que le certificat présent couvre l'hôte demandé, ce qui est la condition réelle à tenir. La CSP `script-src 'self'` bloquait le gestionnaire `onload` que l'inlining du CSS critique d'Angular pose sur la feuille de styles : l'application se serait affichée sans style derrière le proxy. `inlineCritical` passe à faux, le build de production ne produit plus aucun script en ligne. La zone de limitation resserrée ne couvre plus que les routes qui vérifient un secret. Derrière le NAT de l'école, où une seule adresse porte toute la promotion, `/auth/me` et `/auth/refresh` y auraient produit des 429 en usage normal. Enfin `certbot/certbot` est épinglé en v5.8.0 pour que Dependabot puisse le suivre, le proxy attend une API saine plutôt que démarrée, et la redirection vers `$host` est actée comme risque accepté : figer un nom canonique couperait l'accès par adresse IP, seule voie ouverte sur la machine cible. --- Makefile | 18 ++++++++------ README.md | 3 ++- apps/frontend/angular.json | 5 ++++ docker-compose.prod.yml | 14 +++++++---- ...-terminaison-tls-et-reverse-proxy-nginx.md | 24 ++++++++++++++++++- docs/architecture/30-frontend.md | 7 ++++++ infra/proxy/README.md | 13 ++++++++-- infra/proxy/conf.d/enervision.conf | 6 ++++- 8 files changed, 73 insertions(+), 17 deletions(-) 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;