2026-05-09 13:03:36 -05:00
|
|
|
const crypto = require('crypto');
|
2026-05-10 00:03:12 -05:00
|
|
|
const { logAudit } = require('../services/auditService');
|
2026-05-09 13:03:36 -05:00
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// CSRF Middleware
|
|
|
|
|
// Protects state-changing routes (POST, PUT, DELETE) from cross-site request
|
|
|
|
|
// forgery by validating tokens stored in session cookie.
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const CSRF_HEADER_NAME = 'x-csrf-token';
|
|
|
|
|
|
|
|
|
|
// CSRF cookie httpOnly setting - configurable via environment variable
|
2026-05-10 15:25:47 -05:00
|
|
|
// Default: false — the SPA uses a double-submit pattern (reads token from
|
|
|
|
|
// document.cookie and sends it in the x-csrf-token header), which requires
|
|
|
|
|
// JavaScript access to the cookie. Setting httpOnly=true would break this flow.
|
|
|
|
|
// For server-rendered apps, set CSRF_HTTP_ONLY=true for additional protection.
|
|
|
|
|
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY === 'true'; // defaults to false for SPA
|
2026-05-09 13:03:36 -05:00
|
|
|
|
|
|
|
|
// CSRF cookie sameSite setting - configurable via environment variable
|
|
|
|
|
// Options: 'lax', 'strict', 'none'
|
|
|
|
|
// Default: 'strict' (most secure)
|
|
|
|
|
// Set CSRF_SAME_SITE=lax for SPA cross-site scenarios
|
|
|
|
|
const CSRF_SAME_SITE = process.env.CSRF_SAME_SITE || 'strict';
|
|
|
|
|
|
|
|
|
|
// CSRF cookie secure setting - configurable via environment variable
|
|
|
|
|
// Default: true (only send over HTTPS)
|
|
|
|
|
// Set CSRF_SECURE=false for HTTP development (NOT recommended for production)
|
|
|
|
|
const CSRF_SECURE = process.env.CSRF_SECURE !== 'false'; // defaults to true
|
|
|
|
|
|
|
|
|
|
// CSRF cookie name - configurable via environment variable
|
|
|
|
|
// Default: 'bt_csrf_token'
|
|
|
|
|
// Use CSRF_COOKIE_NAME to customize for multi-app deployments
|
|
|
|
|
const CSRF_COOKIE_NAME = process.env.CSRF_COOKIE_NAME || 'bt_csrf_token';
|
|
|
|
|
|
|
|
|
|
// Generate a cryptographically secure CSRF token
|
|
|
|
|
function generateCsrfToken() {
|
|
|
|
|
return crypto.randomBytes(32).toString('hex');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get or create CSRF token for the current session.
|
|
|
|
|
* Tokens are stored in HTTP-only cookies for automatic validation.
|
|
|
|
|
*/
|
|
|
|
|
function getCsrfToken(req, res) {
|
|
|
|
|
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 && req.secure,
|
|
|
|
|
path: '/',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate CSRF token from request.
|
|
|
|
|
* Tokens can be provided via:
|
|
|
|
|
* - x-csrf-token header (API clients)
|
|
|
|
|
* - csrf_token query parameter (form submissions)
|
|
|
|
|
* - csrf_token body field (form submissions)
|
|
|
|
|
*/
|
|
|
|
|
function validateCsrfToken(req) {
|
|
|
|
|
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 queryToken = req.query?.csrf_token;
|
|
|
|
|
if (queryToken && queryToken === 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.
|
|
|
|
|
* Skips validation for: GET, HEAD, OPTIONS (safe methods)
|
|
|
|
|
* Requires token for: POST, PUT, DELETE, PATCH (state-changing)
|
|
|
|
|
*/
|
|
|
|
|
function csrfMiddleware(req, res, next) {
|
|
|
|
|
// Exempt login endpoint - no session exists yet to hijack
|
|
|
|
|
// Check both originalUrl and path for mounted routers
|
|
|
|
|
if (req.originalUrl === '/api/auth/login' || req.path === '/login' || req.path === '/api/auth/login') {
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only validate state-changing methods
|
|
|
|
|
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip validation for OPTIONS (preflight)
|
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Allow API routes to opt-in explicitly via a flag
|
|
|
|
|
// This allows flexibility for routes that use alternate auth (e.g., API keys)
|
|
|
|
|
if (req.csrfSkip) {
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate the CSRF token
|
|
|
|
|
if (!validateCsrfToken(req)) {
|
2026-05-10 00:03:12 -05:00
|
|
|
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.',
|
|
|
|
|
code: 'CSRF_INVALID',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Attach CSRF token to response locals for template rendering.
|
|
|
|
|
* Frontend can access req.csrfToken() in templates.
|
|
|
|
|
*/
|
|
|
|
|
function csrfTokenProvider(req, res, next) {
|
|
|
|
|
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,
|
|
|
|
|
};
|