From 499b753c2266a984dfe77a4782201f02f0d6f050 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:52:52 +0000 Subject: [PATCH] =?UTF-8?q?Supprime=20nginx=20:=20Express=20sert=20le=20fr?= =?UTF-8?q?ontend,=20ports=20peu=20utilis=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Un seul conteneur applicatif : Express sert l'API, le frontend React compilé (fallback SPA, cache immutable sur /assets) et les images /uploads (nosniff + CSP default-src 'none') - En-têtes de sécurité (CSP stricte, X-Frame-Options DENY, etc.) portés de nginx vers helmet, compression gzip ajoutée - Application exposée sur le port 8321 (APP_PORT), PostgreSQL sur 127.0.0.1:56432 (DB_PORT, loopback uniquement pour l'admin locale) - TRUST_PROXY pilote la confiance aux en-têtes X-Forwarded-* (désactivé par défaut hors reverse-proxy) - Dockerfile multi-étages unique à la racine (build React → deps → image finale non-root, fs en lecture seule) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- .dockerignore | 5 +++ .env.example | 10 ++++- Dockerfile | 30 +++++++++++++ README.md | 44 +++++++++++-------- docker-compose.yml | 37 ++++------------ server/.dockerignore | 2 - server/Dockerfile | 18 -------- server/package-lock.json | 49 +++++++++++++++++++++ server/package.json | 1 + server/src/config.js | 3 ++ server/src/index.js | 73 +++++++++++++++++++++++++++++-- server/src/middleware/security.js | 5 ++- web/.dockerignore | 3 -- web/Dockerfile | 13 ------ web/nginx.conf | 55 ----------------------- 15 files changed, 203 insertions(+), 145 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 server/.dockerignore delete mode 100644 server/Dockerfile delete mode 100644 web/.dockerignore delete mode 100644 web/Dockerfile delete mode 100644 web/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1cbd083 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +**/node_modules +**/dist +.env +.git +*.log diff --git a/.env.example b/.env.example index 4df1a6a..6677bc7 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,11 @@ MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026! # Set to "true" when serving over HTTPS (adds Secure flag on cookies) COOKIE_SECURE=false -# Public port of the web frontend -WEB_PORT=8080 +# Set to "true" only when running behind a reverse proxy (TLS termination) +TRUST_PROXY=false + +# Uncommon ports to avoid collisions with other services +# Application (public web port) +APP_PORT=8321 +# PostgreSQL, bound to 127.0.0.1 only (local admin access) +DB_PORT=56432 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5556517 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Stage 1 — build the React frontend +FROM node:22-alpine AS webbuild +WORKDIR /build +COPY web/package.json web/package-lock.json* ./ +RUN npm ci 2>/dev/null || npm install +COPY web/index.html web/vite.config.js ./ +COPY web/public ./public +COPY web/src ./src +RUN npm run build + +# Stage 2 — install API production dependencies +FROM node:22-alpine AS deps +WORKDIR /app +COPY server/package.json server/package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +# Stage 3 — final image: Express serves the API, the frontend and the uploads +FROM node:22-alpine +ENV NODE_ENV=production +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY server/package.json ./ +COPY server/src ./src +COPY --from=webbuild /build/dist ./public +# Upload dir owned by the app user; a named volume mounted here inherits +# this ownership on first use +RUN mkdir -p /data/uploads && chown node:node /data/uploads +USER node +EXPOSE 3000 +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 9daadb5..d783e4a 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,24 @@ back-office d'administration. ## Architecture -Trois conteneurs orchestrés par Docker Compose : +Deux conteneurs orchestrés par Docker Compose, sur des ports peu utilisés +(configurables dans `.env`) : -| Service | Rôle | Exposition | -|---------|------|------------| -| `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | réseau interne uniquement, aucun port publié | -| `api` | API REST Node.js 22 / Express (auth, membres, rencontres, inscriptions, catégories, contenu, uploads) | réseau interne uniquement | -| `web` | nginx (non-root) : frontend React compilé, reverse-proxy `/api`, service des images `/uploads` | port `8080` | +| Service | Rôle | Port hôte | +|---------|------|-----------| +| `app` | Node.js 22 / Express : API REST **et** frontend React compilé **et** images `/uploads` | `8321` (`APP_PORT`) | +| `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | `127.0.0.1:56432` (`DB_PORT`) — loopback uniquement, pour l'administration locale | ``` -Navigateur ──> web (nginx :8080) ──> api (Express :3000) ──> db (PostgreSQL :5432) - │ /uploads (volume partagé, lecture seule) - └─ fichiers statiques React +Navigateur ──> app (Express :8321) ──> db (PostgreSQL, 127.0.0.1:56432) + ├─ /api/… API REST + ├─ /uploads/… images (volume persistant) + └─ /… frontend React (fallback SPA) ``` +Le mapping PostgreSQL est lié à `127.0.0.1` : la base reste inaccessible depuis +le réseau. Supprimez la section `ports:` du service `db` pour la fermer totalement. + - **Frontend** : React 18 + Vite + React Router — reproduction fidèle de la maquette (`SLUC Business Club.dc.html`). - **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs` @@ -39,7 +43,7 @@ cp .env.example .env docker compose up -d --build ``` -L'application est disponible sur (port configurable via `WEB_PORT`). +L'application est disponible sur (port configurable via `APP_PORT`). ## Comptes @@ -98,12 +102,12 @@ formulaire « Mot de passe », endpoint `POST /api/auth/change-password`). (20 / h). - **Uploads** : taille ≤ 2 Mo, type vérifié par octets magiques (jamais le MIME client), nom de fichier aléatoire généré côté serveur (aucune traversée de chemin possible), - servis par nginx avec `X-Content-Type-Options: nosniff` et types MIME forcés. -- **En-têtes** : CSP stricte, `X-Frame-Options: DENY`, `Referrer-Policy`, - `Permissions-Policy`, `helmet` côté API, `server_tokens off`. -- **Conteneurs** : API en utilisateur non-root avec système de fichiers en lecture - seule (`read_only` + tmpfs), nginx non privilégié, `no-new-privileges`, PostgreSQL - sans port publié sur un réseau interne (`internal: true`). + servis avec `X-Content-Type-Options: nosniff` et une CSP `default-src 'none'`. +- **En-têtes** : CSP stricte, `X-Frame-Options: DENY` et toute la panoplie `helmet` + sur l'ensemble des réponses (API, frontend, uploads). +- **Conteneurs** : application en utilisateur non-root avec système de fichiers en + lecture seule (`read_only` + tmpfs), `no-new-privileges`, PostgreSQL publié + uniquement sur `127.0.0.1` (inaccessible depuis le réseau). - **Base de données** : l'API se connecte avec un rôle dédié `sbc_app` limité au DML (pas de DDL, pas de superuser). - **Secrets** : uniquement via `.env` (ignoré par git) ; `docker compose` refuse de @@ -113,7 +117,8 @@ formulaire « Mot de passe », endpoint `POST /api/auth/change-password`). ### Pour la production -- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true`. +- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true` + et `TRUST_PROXY=true`. - Changez immédiatement les mots de passe initiaux. - Sauvegardez les volumes `db_data` (base) et `uploads` (images). @@ -130,8 +135,9 @@ cd web && npm install && npm run dev # proxy /api → localhost:3000 ``` ├── docker-compose.yml +├── Dockerfile # multi-étages : build React → dépendances API → image finale ├── .env.example ├── db/init/ # 01 rôle applicatif · 02 schéma · 03 données de démo -├── server/ # API Express (src/routes, src/middleware, uploads) -└── web/ # React + Vite, nginx.conf, Dockerfile multi-étages +├── server/ # API Express (src/routes, src/middleware, uploads, statique) +└── web/ # sources React + Vite (compilées dans l'image) ``` diff --git a/docker-compose.yml b/docker-compose.yml index 4c3fcc6..9547497 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,17 +12,18 @@ services: volumes: - db_data:/var/lib/postgresql/data - ./db/init:/docker-entrypoint-initdb.d:ro - networks: - - backend + ports: + # Loopback only: reachable from the host machine (psql, backups), + # never from the network. Remove this mapping to close it entirely. + - "127.0.0.1:${DB_PORT:-56432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d sbc"] interval: 5s timeout: 3s retries: 12 - # No published ports: the database is reachable only from the backend network. - api: - build: ./server + app: + build: . restart: unless-stopped environment: NODE_ENV: production @@ -32,15 +33,15 @@ services: ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:-} MEMBER_INITIAL_PASSWORD: ${MEMBER_INITIAL_PASSWORD:-} COOKIE_SECURE: ${COOKIE_SECURE:-false} + TRUST_PROXY: ${TRUST_PROXY:-false} UPLOAD_DIR: /data/uploads volumes: - uploads:/data/uploads + ports: + - "${APP_PORT:-8321}:3000" depends_on: db: condition: service_healthy - networks: - - backend - - frontend read_only: true tmpfs: - /tmp @@ -52,26 +53,6 @@ services: timeout: 5s retries: 6 - web: - build: ./web - restart: unless-stopped - ports: - - "${WEB_PORT:-8080}:8080" - volumes: - - uploads:/var/www/uploads:ro - depends_on: - - api - networks: - - frontend - security_opt: - - no-new-privileges:true - -networks: - backend: - # internal: the db network never routes to the outside world - internal: true - frontend: - volumes: db_data: uploads: diff --git a/server/.dockerignore b/server/.dockerignore deleted file mode 100644 index eb03e3e..0000000 --- a/server/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -*.log diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 368f4d5..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:22-alpine AS deps -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev - -FROM node:22-alpine -ENV NODE_ENV=production -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY package.json ./ -COPY src ./src -# Upload dir owned by the app user; a named volume mounted here inherits -# this ownership on first use -RUN mkdir -p /data/uploads && chown node:node /data/uploads -# Run as the unprivileged built-in user -USER node -EXPOSE 3000 -CMD ["node", "src/index.js"] diff --git a/server/package-lock.json b/server/package-lock.json index a778cd5..4d20a13 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "bcryptjs": "^2.4.3", + "compression": "^1.7.4", "cookie-parser": "^1.4.6", "express": "^4.19.2", "express-rate-limit": "^7.4.0", @@ -135,6 +136,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -805,6 +845,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/server/package.json b/server/package.json index 577549c..92ab359 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "compression": "^1.7.4", "cookie-parser": "^1.4.6", "express": "^4.19.2", "express-rate-limit": "^7.4.0", diff --git a/server/src/config.js b/server/src/config.js index 4b62821..d0decbc 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -12,7 +12,10 @@ export const config = { databaseUrl: required('DATABASE_URL'), jwtSecret: required('JWT_SECRET'), cookieSecure: process.env.COOKIE_SECURE === 'true', + // set to "true" only when running behind a reverse proxy (TLS termination) + trustProxy: process.env.TRUST_PROXY === 'true', uploadDir: process.env.UPLOAD_DIR || '/data/uploads', + staticDir: process.env.STATIC_DIR || new URL('../public', import.meta.url).pathname, adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '', memberInitialPassword: process.env.MEMBER_INITIAL_PASSWORD || '', sessionTtlSeconds: 12 * 60 * 60, diff --git a/server/src/index.js b/server/src/index.js index 5c5b2c8..e1fd47d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,5 +1,8 @@ +import path from 'node:path'; +import fs from 'node:fs'; import express from 'express'; import helmet from 'helmet'; +import compression from 'compression'; import cookieParser from 'cookie-parser'; import { config } from './config.js'; import { waitForDb } from './db.js'; @@ -14,11 +17,33 @@ import { adminRouter } from './routes/admin.js'; const app = express(); app.disable('x-powered-by'); -app.set('trust proxy', 1); // behind nginx -app.use(helmet()); +if (config.trustProxy) app.set('trust proxy', 1); + +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + // 'unsafe-inline' is required for React inline style attributes + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], + fontSrc: ['https://fonts.gstatic.com'], + imgSrc: ["'self'", 'data:', 'blob:'], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: null, + }, + }, + xFrameOptions: { action: 'deny' }, + }) +); +app.use(compression()); app.use(express.json({ limit: '64kb' })); app.use(cookieParser()); -app.use(globalLimiter); +app.use('/api', globalLimiter); app.use(csrfOriginCheck); app.use(attachUser); @@ -27,6 +52,43 @@ app.use('/api/public', publicRouter); app.use('/api/auth', authRouter); app.use('/api/member', memberRouter); app.use('/api/admin', adminRouter); +app.use('/api', (_req, res) => res.status(404).json({ error: 'Introuvable' })); + +// Uploaded images: validated at upload time, served here with hardened headers +app.use( + '/uploads', + express.static(config.uploadDir, { + index: false, + dotfiles: 'deny', + fallthrough: false, + setHeaders: (res) => { + res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + }, + }) +); + +// Built React frontend (production). In dev, Vite serves it with a proxy instead. +if (fs.existsSync(config.staticDir)) { + const indexHtml = path.join(config.staticDir, 'index.html'); + app.use( + express.static(config.staticDir, { + index: false, + setHeaders: (res, filePath) => { + // Vite emits hashed filenames under /assets — safe to cache forever + if (filePath.includes(`${path.sep}assets${path.sep}`)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }, + }) + ); + // SPA fallback for client-side routes + app.get('*', (req, res, next) => { + if (req.path.startsWith('/api/') || req.path.startsWith('/uploads/')) return next(); + res.sendFile(indexHtml); + }); +} app.use((_req, res) => res.status(404).json({ error: 'Introuvable' })); @@ -37,6 +99,9 @@ app.use((err, _req, res, _next) => { } if (err.code === '23503') return res.status(400).json({ error: 'Référence invalide' }); if (err.code === '23505') return res.status(409).json({ error: 'Cette valeur existe déjà.' }); + if (err.statusCode === 404 || err.status === 404) { + return res.status(404).json({ error: 'Introuvable' }); + } console.error(err); res.status(500).json({ error: 'Erreur interne du serveur' }); }); @@ -45,7 +110,7 @@ try { await waitForDb(); await applyInitialPasswords(); app.listen(config.port, () => { - console.log(`SBC API listening on :${config.port}`); + console.log(`SBC app listening on :${config.port}`); }); } catch (err) { console.error('Startup failed:', err); diff --git a/server/src/middleware/security.js b/server/src/middleware/security.js index 5f13e96..5de5572 100644 --- a/server/src/middleware/security.js +++ b/server/src/middleware/security.js @@ -1,11 +1,14 @@ import rateLimit from 'express-rate-limit'; +import { config } from '../config.js'; // CSRF defense-in-depth: session cookie is SameSite=Strict, and every // state-changing request must additionally come from our own origin. +// X-Forwarded-Host is only honoured when explicitly running behind a proxy. export function csrfOriginCheck(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const origin = req.headers.origin || ''; - const host = req.headers['x-forwarded-host'] || req.headers.host || ''; + const host = + (config.trustProxy && req.headers['x-forwarded-host']) || req.headers.host || ''; if (origin) { let originHost; try { diff --git a/web/.dockerignore b/web/.dockerignore deleted file mode 100644 index d4d7e3f..0000000 --- a/web/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -*.log diff --git a/web/Dockerfile b/web/Dockerfile deleted file mode 100644 index d09f914..0000000 --- a/web/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM node:22-alpine AS build -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci 2>/dev/null || npm install -COPY index.html vite.config.js ./ -COPY public ./public -COPY src ./src -RUN npm run build - -FROM nginxinc/nginx-unprivileged:1.27-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 8080 diff --git a/web/nginx.conf b/web/nginx.conf deleted file mode 100644 index 9d4c827..0000000 --- a/web/nginx.conf +++ /dev/null @@ -1,55 +0,0 @@ -server { - listen 8080; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - server_tokens off; - client_max_body_size 3m; - - # Security headers - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "DENY" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; - - gzip on; - gzip_types text/css application/javascript application/json image/svg+xml; - - # API proxied to the backend container - location /api/ { - proxy_pass http://api:3000; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_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; - } - - # Uploaded images: served directly from the shared volume, never executed - location /uploads/ { - alias /var/www/uploads/; - types { image/jpeg jpg; image/png png; image/webp webp; } - default_type application/octet-stream; - # user-uploaded content: never allow it to script or be framed - add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Content-Disposition "inline" always; - add_header Cache-Control "public, max-age=86400" always; - } - - # Static assets (immutable hashed filenames from Vite) - location /assets/ { - add_header Cache-Control "public, max-age=31536000, immutable" always; - add_header X-Content-Type-Options "nosniff" always; - try_files $uri =404; - } - - # SPA fallback - location / { - try_files $uri /index.html; - } -}