queue-med/server/_core/index.ts
Hermes 81c6bccf8a 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>
2026-04-25 13:06:51 +00:00

232 lines
9 KiB
TypeScript

import express from "express";
import http from "node:http";
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, assertAuthEnv } from "../auth.js";
import { getDb } from "../db.js";
import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
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();
// eslint-disable-next-line no-console
console.log("[db] connected");
} catch (err) {
// eslint-disable-next-line no-console
console.error("[db] connection failed:", err);
}
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: ALLOWED_ORIGINS,
credentials: true,
},
path: "/socket.io",
});
(globalThis as unknown as { __socketIo: SocketIOServer }).__socketIo = io;
io.on("connection", (socket) => {
socket.on("clinic:subscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.join(`clinic:${clinicId}`);
});
socket.on("display:subscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.join(`display:${clinicId}`);
});
socket.on("patient:subscribe", (patientToken: string) => {
if (typeof patientToken === "string" && patientToken.length > 0) {
socket.join(`patient:${patientToken}`);
}
});
socket.on("clinic:unsubscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.leave(`clinic:${clinicId}`);
});
socket.on("display:unsubscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.leave(`display:${clinicId}`);
});
socket.on("patient:unsubscribe", (patientToken: string) => {
if (typeof patientToken === "string") socket.leave(`patient:${patientToken}`);
});
});
// ── 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(
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);
// ── Health check ─────────────────────────────────────────────────────────
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", env: NODE_ENV, ts: Date.now() });
});
// ── tRPC ─────────────────────────────────────────────────────────────────
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
onError({ error, path }) {
if (error.code === "INTERNAL_SERVER_ERROR") {
// eslint-disable-next-line no-console
console.error(`[trpc] ${path}:`, error);
}
},
})
);
// ── Static client (production) ───────────────────────────────────────────
if (IS_PROD) {
const clientDist = path.resolve(ROOT, "dist", "client");
const indexHtml = path.resolve(clientDist, "index.html");
if (fs.existsSync(clientDist)) {
app.use(express.static(clientDist, { maxAge: "1h", index: false }));
app.get("*", (req, res, next) => {
if (req.path.startsWith("/api") || req.path.startsWith("/socket.io")) return next();
if (!fs.existsSync(indexHtml)) return next();
res.sendFile(indexHtml);
});
} else {
// eslint-disable-next-line no-console
console.warn(`[static] dist/client not found at ${clientDist}`);
}
}
// ── Error handler ────────────────────────────────────────────────────────
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
// eslint-disable-next-line no-console
console.error("[express] error:", err);
res.status(500).json({ error: "Internal Server Error" });
});
httpServer.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`[server] listening on http://0.0.0.0:${PORT} (${NODE_ENV})`);
// Démarre le job qui marque les patients absents après N minutes sans réponse
startAutoAbsentJob();
});
const shutdown = (signal: string) => {
// eslint-disable-next-line no-console
console.log(`[server] received ${signal}, shutting down`);
stopAutoAbsentJob();
io.close();
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
bootstrap().catch((err) => {
// eslint-disable-next-line no-console
console.error("[server] failed to start:", err);
process.exit(1);
});