BillTracker/middleware/csrf.cts

129 lines
3.7 KiB
TypeScript

import type { Req, Res, Next } from '../types/http';
const crypto = require('crypto');
const { logAudit } = require('../services/auditService.cts');
// ─────────────────────────────────────────────────────────────────────────────
// CSRF Middleware — double-submit token protection for state-changing routes.
// ─────────────────────────────────────────────────────────────────────────────
const CSRF_HEADER_NAME = 'x-csrf-token';
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true
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 {
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');
}
/**
* Get or create CSRF token for the current session (readable double-submit cookie).
*/
function getCsrfToken(req: Req, res: Res): string {
let token = req.cookies?.[CSRF_COOKIE_NAME];
if (!token) {
token = generateCsrfToken();
res.cookie(CSRF_COOKIE_NAME, token, {
httpOnly: CSRF_HTTP_ONLY,
sameSite: CSRF_SAME_SITE,
secure: CSRF_SECURE && requestLooksHttps(req),
path: '/',
});
}
return token;
}
/**
* Validate CSRF token from request (x-csrf-token header or csrf_token body field).
*/
function validateCsrfToken(req: Req): boolean {
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.
*/
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') {
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'),
});
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.',
code: 'CSRF_INVALID',
});
}
next();
}
/**
* Attach CSRF token to response locals for template rendering.
*/
function csrfTokenProvider(req: Req, res: Res, next: Next): void {
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,
};