← 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 };