BillTracker/middleware/csrf.js

141 lines
4.6 KiB
JavaScript

const crypto = require('crypto');
// ─────────────────────────────────────────────────────────────────────────────
// 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
// Default: true (secure, not readable by JavaScript)
// Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true
// 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)) {
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,
};