From 96ee3b831df4f5f5d92b1c61033cebbae3c4efe9 Mon Sep 17 00:00:00 2001 From: Nicolas FRYDER Date: Thu, 20 Aug 2026 16:44:26 +0200 Subject: [PATCH] =?UTF-8?q?chore(infra):=20en-t=C3=AAtes=20de=20s=C3=A9cur?= =?UTF-8?q?it=C3=A9,=20conteneurs=20non-root,=20retrait=20de=20pgAdmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux sites étaient servis par nginx sans aucun en-tête de sécurité : le helmet de l'API ne couvre que les réponses JSON, pas les pages HTML et JS. L'admin, qui déclenche des actions destructrices, reçoit une CSP stricte — script-src 'self' est tenable, son app.js n'utilisant aucun handler inline. Le site public reçoit une CSP plus permissive sur script/style/img, pour ne pas casser la carte, mais verrouille object-src, base-uri et frame-ancestors. pgAdmin est retiré des deux compose sur décision explicite : une interface d'administration de base exposée sur Internet, pour un usage ponctuel. - Images de base épinglées (nginx:alpine → nginx:1.27-alpine). - Utilisateur non-root pour l'api, le crawler et le geocoder. - Le geocoder ne copie plus que src/ au lieu de tout le contexte. - .dockerignore ajouté au crawler : son COPY src embarquait __pycache__. - Keepalive vers l'upstream API (proxy_http_version 1.1 + Connection ""). Co-Authored-By: Claude Opus 5 --- apps/admin/Dockerfile | 2 +- apps/admin/nginx.conf | 16 ++++++++++++++++ apps/api/Dockerfile | 3 +++ apps/crawler/.dockerignore | 7 +++++++ apps/crawler/Dockerfile | 4 ++++ apps/geocoder/Dockerfile | 10 +++++++--- apps/web/Dockerfile | 3 ++- apps/web/nginx.conf | 28 ++++++++++++++++++++++++++++ docker-compose.dev.yml | 22 ---------------------- docker-compose.yml | 22 ---------------------- infra/.env.example | 6 ------ 11 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 apps/crawler/.dockerignore create mode 100644 apps/web/nginx.conf diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index e59a9ad..57a7ac4 100644 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:alpine +FROM nginx:1.27-alpine ARG API_UPSTREAM=http://api:3000 COPY site/ /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/apps/admin/nginx.conf b/apps/admin/nginx.conf index fa327b9..8cbc87c 100644 --- a/apps/admin/nginx.conf +++ b/apps/admin/nginx.conf @@ -3,6 +3,18 @@ server { root /usr/share/nginx/html; index index.html; + server_tokens off; + + # En-têtes de sécurité : le helmet de l'API ne couvre que les réponses JSON, + # pas les pages HTML/JS servies ici. Admin = actions destructrices → strict. + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + # Tout est self-hosted, à une police Google près ( dans index.html). + # script-src 'self' est tenable : app.js n'utilise aucun handler inline. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + # Résolveur DNS interne Docker : force nginx à re-résoudre "api" à chaque # requête au lieu de mettre l'IP en cache au démarrage (sinon un redeploy # du service api laisse nginx pointer vers un conteneur mort -> 502/404). @@ -11,6 +23,8 @@ server { location /admin/api/ { set $upstream_api http://api:3000; proxy_pass $upstream_api; + proxy_http_version 1.1; + proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -20,6 +34,8 @@ server { location /admin/auth/ { set $upstream_api http://api:3000; proxy_pass $upstream_api; + proxy_http_version 1.1; + proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 66df9c5..7bdb7b0 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -7,5 +7,8 @@ RUN npm ci --omit=dev COPY src ./src COPY migrations ./migrations +# node:20-alpine fournit déjà un utilisateur non-root "node" +USER node + ENV PORT=3000 CMD ["sh", "-c", "node src/migrate.js && node src/server.js"] diff --git a/apps/crawler/.dockerignore b/apps/crawler/.dockerignore new file mode 100644 index 0000000..73896e7 --- /dev/null +++ b/apps/crawler/.dockerignore @@ -0,0 +1,7 @@ +.env +*.env +__pycache__/ +*.pyc +*.pyo +.git +.venv diff --git a/apps/crawler/Dockerfile b/apps/crawler/Dockerfile index fd26a5e..283aae6 100644 --- a/apps/crawler/Dockerfile +++ b/apps/crawler/Dockerfile @@ -6,4 +6,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY src ./src +# Utilisateur non-root (le crawler parse du HTML tiers avec lxml) +RUN useradd -m -u 1000 crawler +USER crawler + CMD ["python", "-m", "src.main"] diff --git a/apps/geocoder/Dockerfile b/apps/geocoder/Dockerfile index 10e8e65..f9b4f41 100644 --- a/apps/geocoder/Dockerfile +++ b/apps/geocoder/Dockerfile @@ -6,7 +6,11 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copier le code de l'application -COPY . . +# Copier uniquement le code de l'application +COPY src ./src -# Le command sera spécifié dans docker-compose.yml +# Utilisateur non-root +RUN useradd -m -u 1000 geocoder +USER geocoder + +# Le command est spécifié dans docker-compose.yml (working_dir: /app) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index c89c83c..04bf952 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,4 +1,5 @@ -FROM nginx:alpine +FROM nginx:1.27-alpine ARG API_BASE=https://bm.nicolasfryder.ovh COPY site/ /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf RUN sed -i "s|https://bm.nicolasfryder.ovh|${API_BASE}|g" /usr/share/nginx/html/app.js diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..c19ea91 --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + server_tokens off; + + # En-têtes de sécurité (l'image nginx par défaut n'en pose aucun). + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # CSP du site cartographique public : permissive sur script/style/img/connect + # (Leaflet, tuiles CartoCDN, analytics) pour ne pas casser la carte, mais + # verrouille les sinks dangereux (object-src, base-uri, frame-ancestors). Les + # libs CDN sont en plus protégées par SRI (integrity=) dans index.html. + # + # ⚠ connect-src liste les hôtes d'API EN DUR (dev + prod), alors que l'URL + # utilisée par app.js est substituée au build via ARG API_BASE. Ajouter un + # nouvel environnement impose donc de compléter cette liste, sinon le + # navigateur bloquera silencieusement tous les appels API. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://gc.zgo.at; style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; img-src 'self' data: https:; connect-src 'self' https://bm.nicolasfryder.ovh https://dev-api.metalfrom.eu https://metalfromeurope.goatcounter.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7775cbc..ed5d1c9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -78,25 +78,6 @@ services: - traefik.enable=false restart: unless-stopped - pgadmin: - image: dpage/pgadmin4:8 - environment: - PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - PGADMIN_CONFIG_SERVER_MODE: "True" - volumes: - - bm_dev_pgadmin_data:/var/lib/pgadmin - networks: - - coolify - labels: - - traefik.enable=true - - traefik.docker.network=coolify - - traefik.http.routers.dev-pgadmin.rule=Host(`dev-pgadmin.metalfrom.eu`) - - traefik.http.routers.dev-pgadmin.entrypoints=https - - traefik.http.routers.dev-pgadmin.tls=true - - traefik.http.routers.dev-pgadmin.tls.certresolver=letsencrypt - - traefik.http.services.dev-pgadmin.loadbalancer.server.port=80 - api: build: context: apps/api @@ -157,6 +138,3 @@ networks: coolify: external: true name: coolify - -volumes: - bm_dev_pgadmin_data: diff --git a/docker-compose.yml b/docker-compose.yml index 9046d1f..ac6f215 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,25 +50,6 @@ services: - traefik.enable=false restart: unless-stopped - pgadmin: - image: dpage/pgadmin4:8 - environment: - PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - PGADMIN_CONFIG_SERVER_MODE: "True" - volumes: - - pgadmin_data:/var/lib/pgadmin - networks: - - coolify - labels: - - traefik.enable=true - - traefik.docker.network=coolify - - traefik.http.routers.bm-pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`) - - traefik.http.routers.bm-pgadmin.entrypoints=https - - traefik.http.routers.bm-pgadmin.tls=true - - traefik.http.routers.bm-pgadmin.tls.certresolver=letsencrypt - - traefik.http.services.bm-pgadmin.loadbalancer.server.port=80 - api: build: context: apps/api @@ -129,6 +110,3 @@ networks: coolify: external: true name: coolify - -volumes: - pgadmin_data: diff --git a/infra/.env.example b/infra/.env.example index 998e20b..1da182f 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -35,12 +35,6 @@ GEOAPIFY_API_KEY= # Désambiguïsation LLM des lieux que Geoapify ne résout pas. GROQ_API_KEY= -# ------------------------------------------------------------------ -# pgAdmin -# ------------------------------------------------------------------ -PGADMIN_EMAIL= -PGADMIN_PASSWORD= - # ------------------------------------------------------------------ # Divers # ------------------------------------------------------------------