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:
parent
1fe5b4a618
commit
96ee3b831d
11 changed files with 68 additions and 55 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
7
apps/crawler/.dockerignore
Normal file
7
apps/crawler/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.env
|
||||
*.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
.venv
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
28
apps/web/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue