Skip to content

Commit 71cb2cf

Browse files
committed
feat(security): implement CSRF protection middleware
- Add CSRF middleware using synchronizer token pattern with session storage - Generate tokens with crypto.randomBytes(32) for sufficient entropy - Use timingSafeEqual for constant-time comparison to prevent timing attacks - Rotate token after each state-changing request to prevent replay attacks - Skip CSRF validation for OPTIONS requests (CORS preflight) and JWT-authenticated requests (Bearer token auth is not CSRF-vulnerable) - Expose current token via X-CSRF-Token response header on safe methods - Session SameSite=strict cookie policy already enforced in session config closes #526
1 parent 0418505 commit 71cb2cf

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { DatabaseShutdownService } from './database/services/database-shutdown.s
2222
import { WorkerShutdownService } from './workers/services/worker-shutdown.service';
2323
import { TIME, BYTES } from './common/constants/time.constants';
2424
import { DecompressionMiddleware } from './common/middleware/decompression.middleware';
25+
import { csrfMiddleware } from './middleware/csrf/csrf.middleware';
2526
import { SlackService } from './slack.service';
2627
import compression from 'compression';
2728
import { AuditLogService } from './audit-log/audit-log.service';
@@ -240,6 +241,11 @@ async function bootstrapWorker(): Promise<void> {
240241
next();
241242
});
242243

244+
// =========================
245+
// CSRF PROTECTION
246+
// =========================
247+
app.use(csrfMiddleware);
248+
243249
// =========================
244250
// CORS
245251
// =========================
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { randomBytes, timingSafeEqual } from 'crypto';
2+
import { Request, Response, NextFunction } from 'express';
3+
import { Session, SessionData } from 'express-session';
4+
5+
export const CSRF_TOKEN_HEADER = 'x-csrf-token';
6+
const CSRF_SESSION_KEY = 'csrfToken';
7+
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
8+
9+
type CsrfSessionRequest = Request & {
10+
session?: Session & Partial<SessionData> & { [CSRF_SESSION_KEY]?: string };
11+
};
12+
13+
function generateToken(): string {
14+
return randomBytes(32).toString('hex');
15+
}
16+
17+
function tokensEqual(a: string, b: string): boolean {
18+
if (a.length !== b.length) return false;
19+
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
20+
}
21+
22+
export function csrfMiddleware(req: CsrfSessionRequest, res: Response, next: NextFunction): void {
23+
// Preflight and health-check pass through unconditionally
24+
if (req.method === 'OPTIONS') {
25+
return next();
26+
}
27+
28+
// JWT-authenticated requests carry their own credential; no session cookie
29+
// is involved so CSRF is not applicable for them.
30+
if (req.headers['authorization']) {
31+
return next();
32+
}
33+
34+
if (!req.session) {
35+
return next();
36+
}
37+
38+
if (!STATE_CHANGING_METHODS.has(req.method)) {
39+
if (!req.session[CSRF_SESSION_KEY]) {
40+
req.session[CSRF_SESSION_KEY] = generateToken();
41+
}
42+
res.setHeader(CSRF_TOKEN_HEADER, req.session[CSRF_SESSION_KEY]);
43+
return next();
44+
}
45+
46+
const sessionToken = req.session[CSRF_SESSION_KEY];
47+
const requestToken = req.headers[CSRF_TOKEN_HEADER] as string | undefined;
48+
49+
if (!sessionToken || !requestToken || !tokensEqual(sessionToken, requestToken)) {
50+
res.status(403).json({ message: 'Invalid or missing CSRF token', error: 'Forbidden' });
51+
return;
52+
}
53+
54+
// Rotate after use to prevent replay
55+
req.session[CSRF_SESSION_KEY] = generateToken();
56+
res.setHeader(CSRF_TOKEN_HEADER, req.session[CSRF_SESSION_KEY]);
57+
next();
58+
}

0 commit comments

Comments
 (0)