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