← back to AbramsOS

middleware/csrf.js

49 lines

// CSRF: cookie+body double-submit pattern.
// On every request: ensure a non-httpOnly `aos.csrf` cookie is set.
// On state-changing requests (POST/PUT/DELETE/PATCH) to NON-/api routes:
//   require body field `_csrf` matches the cookie value. Reject 403 if not.
// /api/* routes are exempted because they're called by same-origin JS that
// already carries the httpOnly session cookie + sameSite=lax.

const crypto = require('crypto');

const COOKIE = 'aos.csrf';
const FIELD = '_csrf';
const TTL_MS = 24 * 60 * 60 * 1000;

function ensureToken(req, res, next) {
  let token = req.cookies?.[COOKIE];
  if (!token) {
    token = crypto.randomBytes(24).toString('base64url');
    res.cookie(COOKIE, token, {
      httpOnly: false,            // body must read it
      sameSite: 'lax',
      maxAge: TTL_MS,
      secure: false,              // dev http
    });
  }
  res.locals.csrfToken = token;
  req.csrfToken = token;
  next();
}

function verifyToken(req, res, next) {
  if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
  // /api/* is JSON-only same-origin; trust cookie+sameSite there
  if (req.path.startsWith('/api/')) return next();

  const submitted = req.body?.[FIELD] || req.headers['x-csrf-token'];
  const cookie = req.cookies?.[COOKIE];
  if (!cookie || !submitted) {
    return res.status(403).render('error', { error: 'CSRF token missing. Reload and try again.' });
  }
  const a = Buffer.from(String(submitted));
  const b = Buffer.from(String(cookie));
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(403).render('error', { error: 'CSRF token mismatch. Reload and try again.' });
  }
  next();
}

module.exports = { ensureToken, verifyToken, COOKIE, FIELD };