feat(infra): reverse proxy Nginx et terminaison TLS devant la stack
Le SPA appelle /api/v1 en relatif et rien ne routait cet appel vers l'API une fois en conteneur. Le cookie de rafraîchissement prend le préfixe __Secure- dès que APP_ENV sort de local, donc sans HTTPS il n'était jamais posé et l'authentification ne survivait pas à un rechargement de page. Un service proxy, image officielle nginx dont la configuration est montée en volume, devient le seul composant publié : 80 redirige vers 443 et sert le défi ACME, 443 termine le TLS, sert le SPA sur / et l'API sur /api/ sous la même origine, pose HSTS et CSP que l'application refuse délibérément de poser, et ajoute une limitation de débit au frontal. Backend et frontend ne sont plus publiés, la base et l'interface Mailpit sont ramenées sur la boucle locale. nginx lit toujours les deux mêmes fichiers de certificat : seule leur fabrication varie, script openssl pour la démonstration, deploy-hook certbot le jour où un domaine public existera. Le chemin ACME est livré et documenté, pas exercé : sur une IP privée le défi HTTP-01 ne peut pas aboutir.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# Piège : `X-Forwarded-For` se construit avec `$proxy_add_x_forwarded_for`, qui ajoute l'IP
|
||||
# réelle en fin de chaîne. `get_client_ip()` (apps/backend/app/api/deps.py) ne lit que le
|
||||
# dernier élément : toute autre forme rend la limitation de débit par IP globale, donc le
|
||||
# déni de service auto-infligé que ce code cherche précisément à éviter.
|
||||
# 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.
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# L'application refuse délibérément de poser ces deux en-têtes, verrouillé par
|
||||
# tests/api/test_hardening.py. Ils appartiennent au terminateur TLS, c'est-à-dire ici.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
location /api/v1/auth/ {
|
||||
limit_req zone=auth burst=20 nodelay;
|
||||
set $cible_api http://backend:8000;
|
||||
proxy_pass $cible_api$request_uri;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api burst=40 nodelay;
|
||||
set $cible_api http://backend:8000;
|
||||
proxy_pass $cible_api$request_uri;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $cible_web http://frontend:3000;
|
||||
proxy_pass $cible_web$request_uri;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user