76 lines
2.6 KiB
JavaScript
76 lines
2.6 KiB
JavaScript
import crypto from 'crypto';
|
|
import logger from '../utils/logger.js';
|
|
|
|
const CSRF_COOKIE = '_csrf';
|
|
const CSRF_HEADER = 'x-csrf-token';
|
|
|
|
function generateToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export function csrfMiddleware(req, res, next) {
|
|
let token = req.cookies?.[CSRF_COOKIE];
|
|
if (!token) {
|
|
token = generateToken();
|
|
res.cookie(CSRF_COOKIE, token, {
|
|
httpOnly: false,
|
|
sameSite: false,
|
|
path: '/'
|
|
});
|
|
}
|
|
res.locals.csrfToken = token;
|
|
next();
|
|
}
|
|
|
|
export function validateCsrf(req, res, next) {
|
|
// Only validate state-changing methods
|
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
|
|
|
// Skip multipart/form-data — multer hasn't parsed the body yet.
|
|
// Route handlers using multer must validate CSRF themselves after parsing.
|
|
const ct = req.headers['content-type'] || '';
|
|
if (ct.startsWith('multipart/form-data')) return next();
|
|
|
|
const token = req.body?._csrf || req.headers[CSRF_HEADER];
|
|
const cookieToken = req.cookies?.[CSRF_COOKIE];
|
|
|
|
if (!token || !cookieToken) {
|
|
logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method, hasBodyToken: !!token, hasCookieToken: !!cookieToken, contentType: ct }, 'CSRF token missing');
|
|
return res.status(403).send('CSRF token missing. Please reload the page.');
|
|
}
|
|
|
|
const provided = Buffer.from(token, 'utf8');
|
|
const expected = Buffer.from(cookieToken, 'utf8');
|
|
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token mismatch');
|
|
return res.status(403).send('CSRF token invalid. Please reload the page.');
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* Validate CSRF token from req.body._csrf (for use inside multer handlers).
|
|
* Returns true if valid, false otherwise. Sends 403 response on failure.
|
|
*/
|
|
export function validateCsrfFromBody(req, res) {
|
|
const token = req.body?._csrf;
|
|
const cookieToken = req.cookies?.[CSRF_COOKIE];
|
|
|
|
if (!token || !cookieToken) {
|
|
logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token missing (multipart)');
|
|
res.status(403).send('CSRF token missing. Please reload the page.');
|
|
return false;
|
|
}
|
|
|
|
const provided = Buffer.from(token, 'utf8');
|
|
const expected = Buffer.from(cookieToken, 'utf8');
|
|
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token mismatch (multipart)');
|
|
res.status(403).send('CSRF token invalid. Please reload the page.');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|