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
|
ARG API_UPSTREAM=http://api:3000
|
||||||
COPY site/ /usr/share/nginx/html
|
COPY site/ /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,18 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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
|
# 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
|
# 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).
|
# du service api laisse nginx pointer vers un conteneur mort -> 502/404).
|
||||||
|
|
@ -11,6 +23,8 @@ server {
|
||||||
location /admin/api/ {
|
location /admin/api/ {
|
||||||
set $upstream_api http://api:3000;
|
set $upstream_api http://api:3000;
|
||||||
proxy_pass $upstream_api;
|
proxy_pass $upstream_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
@ -20,6 +34,8 @@ server {
|
||||||
location /admin/auth/ {
|
location /admin/auth/ {
|
||||||
set $upstream_api http://api:3000;
|
set $upstream_api http://api:3000;
|
||||||
proxy_pass $upstream_api;
|
proxy_pass $upstream_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,8 @@ RUN npm ci --omit=dev
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
|
||||||
|
# node:20-alpine fournit déjà un utilisateur non-root "node"
|
||||||
|
USER node
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
CMD ["sh", "-c", "node src/migrate.js && node src/server.js"]
|
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
|
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"]
|
CMD ["python", "-m", "src.main"]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copier le code de l'application
|
# Copier uniquement le code de l'application
|
||||||
COPY . .
|
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
|
ARG API_BASE=https://bm.nicolasfryder.ovh
|
||||||
COPY site/ /usr/share/nginx/html
|
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
|
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
|
- traefik.enable=false
|
||||||
restart: unless-stopped
|
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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: apps/api
|
context: apps/api
|
||||||
|
|
@ -157,6 +138,3 @@ networks:
|
||||||
coolify:
|
coolify:
|
||||||
external: true
|
external: true
|
||||||
name: coolify
|
name: coolify
|
||||||
|
|
||||||
volumes:
|
|
||||||
bm_dev_pgadmin_data:
|
|
||||||
|
|
|
||||||
|
|
@ -50,25 +50,6 @@ services:
|
||||||
- traefik.enable=false
|
- traefik.enable=false
|
||||||
restart: unless-stopped
|
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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: apps/api
|
context: apps/api
|
||||||
|
|
@ -129,6 +110,3 @@ networks:
|
||||||
coolify:
|
coolify:
|
||||||
external: true
|
external: true
|
||||||
name: coolify
|
name: coolify
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgadmin_data:
|
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,6 @@ GEOAPIFY_API_KEY=
|
||||||
# Désambiguïsation LLM des lieux que Geoapify ne résout pas.
|
# Désambiguïsation LLM des lieux que Geoapify ne résout pas.
|
||||||
GROQ_API_KEY=
|
GROQ_API_KEY=
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# pgAdmin
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
PGADMIN_EMAIL=
|
|
||||||
PGADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Divers
|
# Divers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue