120 lines
3.7 KiB
TypeScript
120 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,
|
|
};
|