From 81c6bccf8a07e0a08641d1c932ad9b439d951396 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 25 Apr 2026 13:06:51 +0000 Subject: [PATCH] security: Phase 1 hardening - rate limit, helmet, CORS, JWT, session persistence - express-rate-limit: 100/15min global, 5/15min on auth.login + auth.register, 3/hour reserved for password-reset endpoints; trust proxy enabled. - helmet: enabled with contentSecurityPolicy + crossOriginEmbedderPolicy off to keep Vite dev and the SPA bundle working. - CORS: explicit allowlist (https://attente.cosmolan.fr in prod, localhost in dev), credentials true, restricted methods/headers; same allowlist applied to socket.io. - JWT_SECRET: must be set and >= 32 chars; assertAuthEnv() called from the server bootstrap so the process refuses to start without one. The insecure "changeme-in-production" fallback in docker-compose.yml is removed. - qm_auth cookie: maxAge reduced from 30d to 7d, JWT expiry matches. - WhatsApp sessions: path now driven by WHATSAPP_SESSION_DIR and defaults to /app/data/whatsapp-sessions; docker-compose.yml mounts a named app_data volume so credentials survive container restarts. - scripts/backup-db.sh: timestamped, gzipped mysqldump into /app/data/backups with rotation (keeps last 7); Dockerfile installs mysql-client and bundles the script. - .env.example refreshed with documented placeholders for every required var (DATABASE_URL, JWT_SECRET, WHATSAPP_SESSION_DIR, MYSQL_*, BACKUP_*). Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 26 ++++++++-- Dockerfile | 12 ++++- docker-compose.yml | 13 ++++- package-lock.json | 38 +++++++++++++++ package.json | 2 + scripts/backup-db.sh | 69 ++++++++++++++++++++++++++ server/_core/index.ts | 96 ++++++++++++++++++++++++++++++++++--- server/auth.ts | 19 ++++++-- server/services/whatsapp.ts | 13 +++-- 9 files changed, 268 insertions(+), 20 deletions(-) create mode 100755 scripts/backup-db.sh diff --git a/.env.example b/.env.example index cc2e367..c517b05 100644 --- a/.env.example +++ b/.env.example @@ -2,23 +2,39 @@ # Local dev (host MySQL): # DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed # Docker compose (uses the "db" service): +# DATABASE_URL=mysql://queuemed:queuemed@db:3306/queuemed DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed # ─── Auth ─────────────────────────────────────────────────────────────────── -# Generate a strong random secret, e.g.: openssl rand -hex 64 -JWT_SECRET=change_me_to_a_long_random_string +# REQUIRED. Must be at least 32 characters of high-entropy random data. +# Generate one with: openssl rand -hex 64 +# The server refuses to start if this is missing or too short. +JWT_SECRET=replace_me_with_openssl_rand_hex_64_output # ─── Server ───────────────────────────────────────────────────────────────── PORT=5000 NODE_ENV=development -# Public URL used to build QR code links (e.g. https://queuemed.example.com) +# Public URL used to build QR code links (e.g. https://queuemed.example.com). +# In production this should match the public origin allowed by CORS. PUBLIC_BASE_URL= +# ─── WhatsApp (Baileys) ───────────────────────────────────────────────────── +# Persistent directory used to store Baileys auth credentials per clinic. +# Must live on a Docker volume in production so sessions survive restarts. +WHATSAPP_SESSION_DIR=/app/data/whatsapp-sessions + # ─── Docker compose only ──────────────────────────────────────────────────── -MYSQL_ROOT_PASSWORD=rootpassword +MYSQL_ROOT_PASSWORD=replace_me_with_a_strong_password MYSQL_DATABASE=queuemed MYSQL_USER=queuemed -MYSQL_PASSWORD=queuemed +MYSQL_PASSWORD=replace_me_with_a_strong_password MYSQL_PORT=3306 APP_PORT=5000 + +# ─── Backups (used by scripts/backup-db.sh) ───────────────────────────────── +# Inside the `app` container these point at the `db` service. +# Override only if running the script outside docker compose. +# MYSQL_HOST=db +# BACKUP_DIR=/app/data/backups +# BACKUP_KEEP=7 diff --git a/Dockerfile b/Dockerfile index 057855a..4372d95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,16 @@ COPY package.json tsconfig.json drizzle.config.ts ./ COPY server ./server COPY shared ./shared -# WhatsApp auth sessions dir -RUN mkdir -p /tmp/whatsapp-sessions && chown -R 1000:1000 /tmp/whatsapp-sessions +# Persistent app data directory (WhatsApp sessions, DB backups, …). +# In production this should be backed by a Docker named volume. +RUN mkdir -p /app/data/whatsapp-sessions /app/data/backups + +# Bundle the operational scripts (DB backup, etc.) +COPY scripts ./scripts +RUN chmod +x ./scripts/*.sh || true + +# Tools used by the backup script +RUN apk add --no-cache mysql-client RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app USER app diff --git a/docker-compose.yml b/docker-compose.yml index ddc691c..79ed1fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,8 +33,17 @@ services: NODE_ENV: production PORT: 5000 DATABASE_URL: mysql://${MYSQL_USER:-queuemed}:${MYSQL_PASSWORD:-queuemed}@db:3306/${MYSQL_DATABASE:-queuemed} - JWT_SECRET: ${JWT_SECRET:-changeme-in-production} + # JWT_SECRET MUST be provided via .env.docker — there is no insecure fallback. + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env.docker} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} + WHATSAPP_SESSION_DIR: ${WHATSAPP_SESSION_DIR:-/app/data/whatsapp-sessions} + # MySQL credentials available to scripts/backup-db.sh inside the container + MYSQL_HOST: db + MYSQL_DATABASE: ${MYSQL_DATABASE:-queuemed} + MYSQL_USER: ${MYSQL_USER:-queuemed} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-queuemed} + volumes: + - app_data:/app/data ports: - "5100:5000" networks: @@ -47,3 +56,5 @@ networks: volumes: mysql_data: driver: local + app_data: + driver: local diff --git a/package-lock.json b/package-lock.json index 10383f2..e237036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,9 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.38.2", "express": "^4.21.2", + "express-rate-limit": "^8.4.1", "framer-motion": "^11.15.0", + "helmet": "^8.1.0", "input-otp": "^1.4.1", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.468.0", @@ -5703,6 +5705,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -6061,6 +6081,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -6144,6 +6173,15 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 9c94fc7..9be602d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.38.2", "express": "^4.21.2", + "express-rate-limit": "^8.4.1", "framer-motion": "^11.15.0", + "helmet": "^8.1.0", "input-otp": "^1.4.1", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.468.0", diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..5bd9064 --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# QueueMed — MySQL backup helper. +# +# Dumps the configured MySQL database into /app/data/backups (or $BACKUP_DIR) +# with a timestamped filename, then prunes everything but the 7 most recent +# backups. Designed to run inside the `app` container — schedule from the host +# with `docker compose exec app /app/scripts/backup-db.sh` (cron, systemd, …). +# +# Required environment variables (already wired through docker-compose.yml): +# MYSQL_HOST – hostname of the MySQL service (default: db) +# MYSQL_DATABASE – database name to dump (default: queuemed) +# MYSQL_USER – MySQL user (default: queuemed) +# MYSQL_PASSWORD – MySQL password (required) +# Optional: +# BACKUP_DIR – override backup destination (default: /app/data/backups) +# BACKUP_KEEP – number of backups to retain (default: 7) + +set -eu + +MYSQL_HOST="${MYSQL_HOST:-db}" +MYSQL_DATABASE="${MYSQL_DATABASE:-queuemed}" +MYSQL_USER="${MYSQL_USER:-queuemed}" +BACKUP_DIR="${BACKUP_DIR:-/app/data/backups}" +BACKUP_KEEP="${BACKUP_KEEP:-7}" + +if [ -z "${MYSQL_PASSWORD:-}" ]; then + echo "[backup-db] MYSQL_PASSWORD is not set, aborting." >&2 + exit 1 +fi + +mkdir -p "$BACKUP_DIR" + +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" +OUT_FILE="$BACKUP_DIR/${MYSQL_DATABASE}-${TIMESTAMP}.sql.gz" +TMP_FILE="${OUT_FILE}.partial" + +echo "[backup-db] dumping ${MYSQL_DATABASE} from ${MYSQL_HOST} -> ${OUT_FILE}" + +# --single-transaction keeps the dump consistent without locking InnoDB tables. +# --quick streams rows row-by-row to keep memory bounded for large tables. +mysqldump \ + --host="$MYSQL_HOST" \ + --user="$MYSQL_USER" \ + --password="$MYSQL_PASSWORD" \ + --single-transaction \ + --quick \ + --routines \ + --triggers \ + --no-tablespaces \ + --default-character-set=utf8mb4 \ + "$MYSQL_DATABASE" | gzip -9 > "$TMP_FILE" + +mv "$TMP_FILE" "$OUT_FILE" + +echo "[backup-db] backup written: $OUT_FILE ($(wc -c < "$OUT_FILE") bytes)" + +# ── Rotate: keep only the last $BACKUP_KEEP backups ───────────────────────── +# `ls -1t` sorts by mtime descending; everything after the first $BACKUP_KEEP +# entries is removed. Filenames are constrained to our prefix to avoid eating +# unrelated files that might share the directory. +cd "$BACKUP_DIR" +ls -1t "${MYSQL_DATABASE}"-*.sql.gz 2>/dev/null \ + | awk -v keep="$BACKUP_KEEP" 'NR > keep' \ + | while IFS= read -r old; do + echo "[backup-db] pruning old backup: $old" + rm -f -- "$old" + done + +echo "[backup-db] done." diff --git a/server/_core/index.ts b/server/_core/index.ts index d7d8b4e..99fe0fa 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -4,12 +4,14 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; import cookieParser from "cookie-parser"; import { Server as SocketIOServer } from "socket.io"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { appRouter } from "../routers.js"; import { createContext } from "./context.js"; -import { authMiddleware } from "../auth.js"; +import { authMiddleware, assertAuthEnv } from "../auth.js"; import { getDb } from "../db.js"; import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js"; @@ -19,7 +21,20 @@ const PORT = Number(process.env.PORT ?? 5000); const NODE_ENV = process.env.NODE_ENV ?? "development"; const IS_PROD = NODE_ENV === "production"; +const PROD_ORIGINS = ["https://attente.cosmolan.fr"]; +const DEV_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:5000", + "http://127.0.0.1:5000", +]; +const ALLOWED_ORIGINS = IS_PROD ? PROD_ORIGINS : DEV_ORIGINS; + async function bootstrap() { + // Fail fast if critical secrets are missing — refuse to start instead of + // erroring lazily on the first authenticated request. + assertAuthEnv(); + // Eagerly initialize database connection (warns early if DATABASE_URL missing) try { await getDb(); @@ -31,12 +46,14 @@ async function bootstrap() { } const app = express(); + // Required for express-rate-limit and secure cookies behind a reverse proxy + app.set("trust proxy", 1); const httpServer = http.createServer(app); // ── Socket.io ──────────────────────────────────────────────────────────── const io = new SocketIOServer(httpServer, { cors: { - origin: IS_PROD ? false : true, + origin: ALLOWED_ORIGINS, credentials: true, }, path: "/socket.io", @@ -66,13 +83,80 @@ async function bootstrap() { }); }); - // ── Middlewares ────────────────────────────────────────────────────────── + // ── Security headers ───────────────────────────────────────────────────── + // CSP is intentionally disabled here: it conflicts with the Vite dev server + // and the inline assets generated by the SPA build. Re-enable once a proper + // policy has been defined for the production bundle. app.use( - cors({ - origin: IS_PROD ? false : ["http://localhost:5173", "http://127.0.0.1:5173"], - credentials: true, + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, }) ); + + // ── CORS ───────────────────────────────────────────────────────────────── + app.use( + cors({ + origin: (origin, callback) => { + // Allow same-origin / non-browser requests (no Origin header) + if (!origin) return callback(null, true); + if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true); + return callback(new Error(`Origin not allowed by CORS: ${origin}`)); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + }) + ); + + // ── Rate limiting ──────────────────────────────────────────────────────── + // Global limiter applied to the whole API surface. + const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests, please try again later." }, + }); + + // Stricter limiter for authentication endpoints (login + register). + const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, + message: { error: "Too many auth attempts, please try again later." }, + }); + + // Password reset limiter — kept ready in case a reset endpoint is added. + const passwordResetLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many password reset attempts, please try again later." }, + }); + + // Apply auth limiter BEFORE the tRPC middleware so it short-circuits abuse. + app.use( + [ + "/api/trpc/auth.login", + "/api/trpc/auth.register", + ], + authLimiter + ); + app.use( + [ + "/api/trpc/auth.requestPasswordReset", + "/api/trpc/auth.resetPassword", + "/api/trpc/auth.forgotPassword", + ], + passwordResetLimiter + ); + app.use("/api", globalLimiter); + + // ── Body / cookies / auth ──────────────────────────────────────────────── app.use(express.json({ limit: "1mb" })); app.use(cookieParser()); app.use(authMiddleware); diff --git a/server/auth.ts b/server/auth.ts index 9ba747c..30a1f14 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -5,14 +5,27 @@ import { getUserById } from "./db.js"; import type { User } from "./schema.js"; const COOKIE_NAME = "qm_auth"; -const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days function getJwtSecret(): string { const secret = process.env.JWT_SECRET; - if (!secret) throw new Error("JWT_SECRET is not set"); + if (!secret || secret.length < 32) { + throw new Error( + "JWT_SECRET is not set or is too short (>= 32 chars required). " + + "Generate one with `openssl rand -hex 64` and set it in the environment." + ); + } return secret; } +/** + * Fail-fast validation called from the server bootstrap so a misconfigured + * deployment refuses to start instead of erroring lazily on the first login. + */ +export function assertAuthEnv(): void { + getJwtSecret(); +} + export interface JwtPayload { sub: number; email: string; @@ -35,7 +48,7 @@ export function createToken(user: Pick): string { email: user.email, role: user.role, }; - return jwt.sign(payload, getJwtSecret(), { expiresIn: "30d" }); + return jwt.sign(payload, getJwtSecret(), { expiresIn: "7d" }); } export function verifyToken(token: string): JwtPayload | null { diff --git a/server/services/whatsapp.ts b/server/services/whatsapp.ts index d2d2b78..8ee506b 100644 --- a/server/services/whatsapp.ts +++ b/server/services/whatsapp.ts @@ -1,7 +1,9 @@ /** * WhatsApp notification service using Baileys (WhatsApp Web unofficial API) * Manages one WhatsApp session per clinic (identified by clinicId). - * Sessions are stored in /tmp/whatsapp-sessions// + * Sessions are stored in $WHATSAPP_SESSION_DIR//, which defaults + * to /app/data/whatsapp-sessions inside the container so credentials survive + * container restarts (the path must live on a persistent Docker volume). * * ⚠️ Disclaimer: Baileys uses the WhatsApp Web protocol which is unofficial. * Use at low volume (< 500 msg/day) to minimise ban risk. @@ -51,8 +53,13 @@ function getMessageQueue(clinicId: number): PQueue { return messageQueues.get(clinicId)!; } -const SESSION_DIR = "/tmp/whatsapp-sessions"; -if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true }); +const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR ?? "/app/data/whatsapp-sessions"; +try { + fs.mkdirSync(SESSION_DIR, { recursive: true }); +} catch (err) { + // eslint-disable-next-line no-console + console.error(`[whatsapp] failed to create session dir at ${SESSION_DIR}:`, err); +} // Silent logger to avoid spamming server logs const logger = pino({ level: "silent" });