BillTracker/middleware/csrf.cts

129 lines
3.7 KiB
TypeScript
Raw Normal View History

import type { Req, Res, Next } from '../types/http';
2026-05-09 13:03:36 -05:00
const crypto = require('crypto');
const { logAudit } = require('../services/auditService.cts');
2026-05-09 13:03:36 -05:00
// ─────────────────────────────────────────────────────────────────────────────
// CSRF Middleware — double-submit token protection for state-changing routes.
2026-05-09 13:03:36 -05:00
// ─────────────────────────────────────────────────────────────────────────────
const CSRF_HEADER_NAME = 'x-csrf-token';
2026-05-31 15:52:50 -05:00
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true
2026-05-09 13:03:36 -05:00
const CSRF_SAME_SITE = process.env.CSRF_SAME_SITE || 'strict';
const CSRF_SECURE = process.env.CSRF_SECURE !== 'false'; // defaults to true
const CSRF_COOKIE_NAME = process.env.CSRF_COOKIE_NAME || 'bt_csrf_token';
function generateCsrfToken(): string {
2026-05-09 13:03:36 -05:00
return crypto.randomBytes(32).toString('hex');
}
/**
* Detect HTTPS the same way services/authService.cookieOpts does.
*/
function requestLooksHttps(req?: Req): boolean {
if (!req) return false;
if (req.secure) return true;
const proto = req.get?.('x-forwarded-proto') || req.headers?.['x-forwarded-proto'];
return String(proto || '')
.split(',')
.map((s: string) => s.trim())
.includes('https');
}
2026-05-09 13:03:36 -05:00
/**
* Get or create CSRF token for the current session (readable double-submit cookie).
2026-05-09 13:03:36 -05:00
*/
function getCsrfToken(req: Req, res: Res): string {
2026-05-09 13:03:36 -05:00
let token = req.cookies?.[CSRF_COOKIE_NAME];
2026-05-09 13:03:36 -05:00
if (!token) {
token = generateCsrfToken();
res.cookie(CSRF_COOKIE_NAME, token, {
httpOnly: CSRF_HTTP_ONLY,
sameSite: CSRF_SAME_SITE,
secure: CSRF_SECURE && requestLooksHttps(req),
2026-05-09 13:03:36 -05:00
path: '/',
});
}
2026-05-09 13:03:36 -05:00
return token;
}
/**
* Validate CSRF token from request (x-csrf-token header or csrf_token body field).
2026-05-09 13:03:36 -05:00
*/
function validateCsrfToken(req: Req): boolean {
2026-05-09 13:03:36 -05:00
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
if (!cookieToken) return false;
const headerToken = req.headers?.[CSRF_HEADER_NAME];
if (headerToken && headerToken === cookieToken) return true;
const bodyToken = req.body?.csrf_token;
if (bodyToken && bodyToken === cookieToken) return true;
return false;
}
/**
* CSRF middleware validates tokens for state-changing methods.
2026-05-09 13:03:36 -05:00
*/
function csrfMiddleware(req: Req, res: Res, next: Next): any {
// Exempt the login endpoint only — no session exists yet to hijack.
const fullPath = (req.originalUrl || '').split('?')[0];
if (fullPath === '/api/auth/login') {
2026-05-09 13:03:36 -05:00
return next();
}
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
return next();
}
if (req.method === 'OPTIONS') {
return next();
}
if (req.csrfSkip) {
return next();
}
if (!validateCsrfToken(req)) {
logAudit({
user_id: req.user?.id || null,
action: 'csrf.failure',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
2026-05-09 13:03:36 -05:00
return res.status(403).json({
error: 'CSRF token validation failed',
message:
'Your session has expired or this request may be fraudulent. Please refresh the page and try again.',
2026-05-09 13:03:36 -05:00
code: 'CSRF_INVALID',
});
}
next();
}
/**
* Attach CSRF token to response locals for template rendering.
*/
function csrfTokenProvider(req: Req, res: Res, next: Next): void {
2026-05-09 13:03:36 -05:00
res.locals.csrfToken = getCsrfToken(req, res);
next();
}
module.exports = {
CSRF_COOKIE_NAME,
CSRF_HEADER_NAME,
CSRF_HTTP_ONLY,
CSRF_SAME_SITE,
CSRF_SECURE,
generateCsrfToken,
getCsrfToken,
validateCsrfToken,
csrfMiddleware,
csrfTokenProvider,
};