Supprime nginx : Express sert le frontend, ports peu utilisés

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
Claude committed 2026-07-10 19:52:52 +00:00
1 parent 96dca2a802
commit 499b753c22
15 files changed
+203 -145

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
node_modules
*.log
-18
View File
@@ -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"]
+49
View File
@@ -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",
+1
View File
@@ -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",
+3
View File
@@ -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,
+69 -4
View File
@@ -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);
+4 -1
View File
@@ -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 {