diff --git a/src/main.ts b/src/main.ts index 1109aa28..55055142 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,7 @@ import { DatabaseShutdownService } from './database/services/database-shutdown.s import { WorkerShutdownService } from './workers/services/worker-shutdown.service'; import { TIME, BYTES } from './common/constants/time.constants'; import { DecompressionMiddleware } from './common/middleware/decompression.middleware'; +import { csrfMiddleware } from './middleware/csrf/csrf.middleware'; import { SlackService } from './slack.service'; import compression from 'compression'; import { AuditLogService } from './audit-log/audit-log.service'; @@ -240,6 +241,11 @@ async function bootstrapWorker(): Promise { next(); }); + // ========================= + // CSRF PROTECTION + // ========================= + app.use(csrfMiddleware); + // ========================= // CORS // ========================= diff --git a/src/middleware/csrf/csrf.middleware.ts b/src/middleware/csrf/csrf.middleware.ts new file mode 100644 index 00000000..21fc2756 --- /dev/null +++ b/src/middleware/csrf/csrf.middleware.ts @@ -0,0 +1,58 @@ +import { randomBytes, timingSafeEqual } from 'crypto'; +import { Request, Response, NextFunction } from 'express'; +import { Session, SessionData } from 'express-session'; + +export const CSRF_TOKEN_HEADER = 'x-csrf-token'; +const CSRF_SESSION_KEY = 'csrfToken'; +const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']); + +type CsrfSessionRequest = Request & { + session?: Session & Partial & { [CSRF_SESSION_KEY]?: string }; +}; + +function generateToken(): string { + return randomBytes(32).toString('hex'); +} + +function tokensEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); +} + +export function csrfMiddleware(req: CsrfSessionRequest, res: Response, next: NextFunction): void { + // Preflight and health-check pass through unconditionally + if (req.method === 'OPTIONS') { + return next(); + } + + // JWT-authenticated requests carry their own credential; no session cookie + // is involved so CSRF is not applicable for them. + if (req.headers['authorization']) { + return next(); + } + + if (!req.session) { + return next(); + } + + if (!STATE_CHANGING_METHODS.has(req.method)) { + if (!req.session[CSRF_SESSION_KEY]) { + req.session[CSRF_SESSION_KEY] = generateToken(); + } + res.setHeader(CSRF_TOKEN_HEADER, req.session[CSRF_SESSION_KEY]); + return next(); + } + + const sessionToken = req.session[CSRF_SESSION_KEY]; + const requestToken = req.headers[CSRF_TOKEN_HEADER] as string | undefined; + + if (!sessionToken || !requestToken || !tokensEqual(sessionToken, requestToken)) { + res.status(403).json({ message: 'Invalid or missing CSRF token', error: 'Forbidden' }); + return; + } + + // Rotate after use to prevent replay + req.session[CSRF_SESSION_KEY] = generateToken(); + res.setHeader(CSRF_TOKEN_HEADER, req.session[CSRF_SESSION_KEY]); + next(); +}