UNPKG

mini-csrf

Version:

A small, stateless, session-less CSRF protection middleware for Express

125 lines (102 loc) 3.62 kB
import crypto from "crypto"; export function constantTimeEquals(a, b) { const strA = String(a || ""); const strB = String(b || ""); // result is an accumulator for all the errors let result = strA.length ^ strB.length; const maxLength = Math.max(strA.length, strB.length); for (let i = 0; i < maxLength; i++) { const charA = i < strA.length ? strA.charCodeAt(i) : 0; const charB = i < strB.length ? strB.charCodeAt(i) : 0; result |= charA ^ charB; } return result === 0; } export function validateFieldName(name, type) { if (typeof name !== "string" || !/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error( `Invalid ${type} field name: must contain only alphanumeric characters, underscores, or hyphens` ); } } export default function csrfProtection(options) { // Validate options parameter if (!options || typeof options !== "object") { throw new Error("Options parameter is required and must be an object"); } // Destructure with defaults after validation const { secret, fieldNames = { token: "_csrf_token", time: "_csrf_time" }, ttl = 3600_000, } = options; // Validate secret if (!secret || typeof secret !== "string" || secret.length < 32) { throw new Error("CSRF secret must be at least 32 characters long"); } // Validate fieldNames if (fieldNames !== null && typeof fieldNames !== "object") { throw new Error("fieldNames must be an object"); } validateFieldName(fieldNames.token, "token"); validateFieldName(fieldNames.time, "time"); if (fieldNames.token === fieldNames.time) { throw new Error("Token and time field names must be different"); } // Validate ttl if (typeof ttl !== "number" || ttl <= 0 || !Number.isFinite(ttl)) { throw new Error("ttl must be a positive finite number"); } // Generate HMAC token function generateToken(userIdentifier, timestamp) { return crypto .createHmac("sha256", secret) .update(userIdentifier + timestamp) .digest("hex"); } // Extract user identifier from request (IP + UA) function getUserIdentifier(req) { return (req.ip || "") + (req.headers["user-agent"] || ""); } // Middleware to validate CSRF on unsafe methods function middleware(req, res, next) { const method = req.method.toUpperCase(); // Skip validation for non-mutable methods if (["GET", "HEAD", "OPTIONS"].includes(method)) return next(); const token = req.body?.[fieldNames.token]; const time = req.body?.[fieldNames.time]; const now = Date.now(); if (!token || !time) { return next(makeCsrfError("Missing CSRF token or timestamp")); } const expected = generateToken(getUserIdentifier(req), time); const age = now - Number(time); if (!constantTimeEquals(token, expected)) { return next(makeCsrfError("Invalid CSRF token")); } if (isNaN(age) || age < 0 || age > ttl) { return next(makeCsrfError("Expired CSRF token")); } next(); } // Helper: HTML string of hidden input fields function csrfTokenHtml(req) { const time = Date.now().toString(); const userId = getUserIdentifier(req); const token = generateToken(userId, time); // field names are checked for safety at middleware creation return ` <input type="hidden" name="${fieldNames.token}" value="${token}" /> <input type="hidden" name="${fieldNames.time}" value="${time}" /> `; } function makeCsrfError(message) { const err = new Error(message); err.code = "EBADCSRFTOKEN"; return err; } return { middleware, csrfTokenHtml, }; }