chore(infra): en-têtes de sécurité, conteneurs non-root, retrait de pgAdmin

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 <noreply@anthropic.com>
This commit is contained in:
Nicolas FRYDER 2026-08-20 16:44:26 +02:00
parent 1fe5b4a618
commit 96ee3b831d
11 changed files with 68 additions and 55 deletions

View file

@ -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

View file

@ -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 (<link> 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;

View file

@ -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"]

View file

@ -0,0 +1,7 @@
.env
*.env
__pycache__/
*.pyc
*.pyo
.git
.venv

View file

@ -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"]

View file

@ -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)

View file

@ -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

28
apps/web/nginx.conf Normal file
View file

@ -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;
}
}

View file

@ -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:

View file

@ -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:

View file

@ -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
# ------------------------------------------------------------------