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:
15 files changed
+203
-145
No files matched your search
@@ -0,0 +1,5 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
.env
|
||||
.git
|
||||
*.log
|
||||
+8
-2
@@ -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
|
||||
+30
@@ -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"]
|
||||
@@ -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 <http://localhost:8080> (port configurable via `WEB_PORT`).
|
||||
L'application est disponible sur <http://localhost:8321> (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)
|
||||
```
|
||||
+9
-28
@@ -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:
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
*.log
|
||||
@@ -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"]
|
||||
Generated
+49
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user