← back to NationalPaperHangers
lib/csrf.js
57 lines
// Minimal CSRF protection. Generates a per-session token, exposes it via
// res.locals.csrfToken for forms, and validates state-changing requests via
// either the `_csrf` form field or the `X-CSRF-Token` header.
//
// Skips: GET/HEAD/OPTIONS, /webhooks/* (Stripe sig handles auth), and any
// request explicitly marked exempt via `req.skipCsrf = true` upstream.
const crypto = require('crypto');
function ensureToken(req) {
if (!req.session) return null;
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(24).toString('base64url');
}
return req.session.csrfToken;
}
function csrfMiddleware(req, res, next) {
const token = ensureToken(req);
res.locals.csrfToken = token;
const safe = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
if (safe) return next();
// Webhook routes are mounted before this middleware in server.js, so this
// is belt-and-suspenders. Stripe signature handles their auth.
if (req.path.startsWith('/webhooks/')) return next();
if (req.skipCsrf) return next();
const supplied = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
if (!token || !supplied) return reject(req, res);
let a, b;
try {
a = Buffer.from(token);
b = Buffer.from(supplied);
} catch {
return reject(req, res);
}
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return reject(req, res);
next();
}
function reject(req, res) {
if (req.accepts('json') && !req.accepts('html')) {
return res.status(403).json({ error: 'csrf_failed' });
}
return res.status(403).render('public/error', {
title: 'Request blocked',
message: 'Form expired or session lost — please reload the page and try again.'
});
}
module.exports = { csrfMiddleware, ensureToken };