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-31 15:52:50 -05:00
|
|
|
// Default: true — the SPA fetches the token from GET /api/auth/csrf-token and stores
|
|
|
|
|
// it in memory, so JavaScript does not need direct access to document.cookie.
|
|
|
|
|
// httpOnly=true removes the token from the XSS-accessible cookie surface while
|
|
|
|
|
// preserving the double-submit validation on the server.
|
|
|
|
|
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:28:54 -05:00
|
|
|
/**
|
|
|
|
|
* Detect HTTPS the same way services/authService.cookieOpts does:
|
|
|
|
|
* req.secure (honors trust proxy) with an x-forwarded-proto fallback for
|
|
|
|
|
* deployments where TRUST_PROXY is not configured.
|
|
|
|
|
*/
|
|
|
|
|
function requestLooksHttps(req) {
|
|
|
|
|
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 => s.trim()).includes('https');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
/**
|
|
|
|
|
* Get or create CSRF token for the current session.
|
2026-05-15 22:45:38 -05:00
|
|
|
* In the SPA's double-submit flow, tokens are stored in a readable cookie so
|
|
|
|
|
* client/api.js can copy the value into the x-csrf-token header.
|
2026-05-09 13:03:36 -05:00
|
|
|
*/
|
|
|
|
|
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,
|
2026-06-10 19:28:54 -05:00
|
|
|
secure: CSRF_SECURE && requestLooksHttps(req),
|
2026-05-09 13:03:36 -05:00
|
|
|
path: '/',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate CSRF token from request.
|
|
|
|
|
* Tokens can be provided via:
|
|
|
|
|
* - x-csrf-token header (API clients)
|
|
|
|
|
* - csrf_token body field (form submissions)
|
2026-06-10 19:28:54 -05:00
|
|
|
* Query-parameter tokens are deliberately NOT accepted — URLs leak into
|
|
|
|
|
* access logs, browser history, and Referer headers.
|
2026-05-09 13:03:36 -05:00
|
|
|
*/
|
|
|
|
|
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 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) {
|
2026-06-10 19:28:54 -05:00
|
|
|
// Exempt the login endpoint only — no session exists yet to hijack.
|
|
|
|
|
// Compare against originalUrl (sans query string) so a "/login" subpath on
|
|
|
|
|
// some other mounted router is NOT accidentally exempted.
|
|
|
|
|
const fullPath = (req.originalUrl || '').split('?')[0];
|
|
|
|
|
if (fullPath === '/api/auth/login') {
|
2026-05-09 13:03:36 -05:00
|
|
|
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,
|
|
|
|
|
};
|