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) <noreply@anthropic.com>
This commit is contained in:
Hermes 2026-04-25 13:06:51 +00:00
parent 1dbb131d24
commit 81c6bccf8a
9 changed files with 268 additions and 20 deletions

View file

@ -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

View file

@ -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

View file

@ -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

38
package-lock.json generated
View file

@ -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",

View file

@ -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",

69
scripts/backup-db.sh Executable file
View file

@ -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."

View file

@ -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);

View file

@ -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<User, "id" | "email" | "role">): 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 {

View file

@ -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/<clinicId>/
* Sessions are stored in $WHATSAPP_SESSION_DIR/<clinicId>/, 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" });