UNPKG

csrf-crypto

Version:

Connect middleware for session-less CSRF protection using cryptography.

259 lines (205 loc) 8.16 kB
"use strict"; var crypto = require('crypto'); var defaultAlgorithm = 'sha512', defaultCookieName = '_csrfKey', saltSize = 20, emptyUserData = function () { return ""; }; // These keys are used to transform the caller's master key // into two separate keys for the cookie and form tokens. var cookieKeyKey = Buffer.from('55db309354097fca60732bf300f04e1880b285dcd506437c052e13d33313ada04c43f6cb613ae2a38affc693b4fbce3df7521eaf5ba200a2c95693f64f343fc5', 'hex'); var formKeyKey = Buffer.from('6a23031b0b18777157cabf8aed626e298720115c2dbf4b73af790cda42fb4bdb2afd3d1909526b519c2bd5ccded109ddb0caef43b743329c6df2bb8ef49bec08', 'hex'); function base64Random(numBytes) { var bytes = crypto.randomBytes(numBytes); return Buffer.from(bytes, 'binary').toString('base64'); } function allowCsrf() { /*jshint validthis:true */ this._csrfAllowed = true; } module.exports = function csrfCrypto(options) { if (!options || !options.key) throw new Error("csrf-crypto requires a key"); options.algorithm = options.algorithm || defaultAlgorithm; var cookieName; if (typeof options.cookieName === 'function') cookieName = options.cookieName; else if (options.cookieName) cookieName = function () { return options.cookieName; }; else cookieName = function () { return defaultCookieName; }; var getUserData; if (!options.userData) { getUserData = emptyUserData; } else { // Since we use '|' to split parts, make sure that the userData // does not contain that character. getUserData = function (req) { return String(options.userData(req)).replace(/[|]/g, '^'); }; } var cookieDomain; if (typeof options.domain === 'function') cookieDomain = options.domain; else if (options.domain) cookieDomain = function () { return options.domain; }; else if (options.allowSubdomains) cookieDomain = function (req) { return req.host && req.host.indexOf('.') >= 0 ? '.' + req.host : void 0; }; // For security reasons, browsers won't set wildcard cookies on top-level domains, even `localhost` else cookieDomain = function () { }; var cookieKey = crypto.createHmac(options.algorithm, options.key).update(cookieKeyKey).digest(); var formKey = crypto.createHmac(options.algorithm, options.key).update(formKeyKey).digest(); function checkSecure(req) { if (options.secure && !req.secure) throw new Error("csrf-crypto has been configured to require SSL; cannot call CSRF functions in non-HTTPS request to " + req.originalUrl); } // The cookie has three parts: // Random salt, userData (if any), and a hash of the previous two. // If the caller provided a userData() function, the userData from // the cookie will be checked against it during token verification // Private function that adds a new cookie token to the response and returns its salt value function createCookie(res) { var salt = base64Random(saltSize); var userData = getUserData(res.req); var hasher = crypto.createHmac(options.algorithm, cookieKey); hasher.update(salt); hasher.update("|"); // Don't confuse longer or shorter userData's hasher.update(userData); var hash = hasher.digest('base64'); var cookie = salt + "|" + userData + "|" + hash; res.cookie(cookieName(res.req), cookie, { httpOnly: true, secure: options.secure, domain: cookieDomain(res.req) }); return salt; } // Private function that finds an existing cookie token and returns its salt value function getCookieToken(res) { var value = res.req.cookies[cookieName(res.req)]; if (!value) return false; var parts = value.split('|'); // If the existing cookie is invalid, reject it. if (parts.length !== 3) return false; // If the user data doesn't match this request's user, reject the cookie if (parts[1] !== getUserData(res.req)) return false; var hasher = crypto.createHmac(options.algorithm, cookieKey); hasher.update(parts[0]); hasher.update("|"); // Don't confuse longer or shorter userDatas hasher.update(parts[1]); // If the hash doesn't match, reject the cookie if (parts[2] !== hasher.digest('base64')) return false; return parts[0]; } /** * Clears the CSRF cookie token and removes any cached data. * This function must be called on the response object. * * @returns {String} An opaque token to include with new requests. */ function resetCsrf() { /*jshint validthis:true */ this.clearCookie(cookieName(this.req), { domain: cookieDomain(this.req) }); delete this._csrfFormToken; } /** * Gets a new form token for the current response. * This function must be called on the response object. * * @returns {String} An opaque token to include with new requests. */ function getFormToken() { /*jshint validthis:true */ if (this._csrfFormToken) return this._csrfFormToken; checkSecure(this.req); var cookieToken = getCookieToken(this) || createCookie(this); var salt = base64Random(saltSize); var hasher = crypto.createHmac(options.algorithm, formKey); hasher.update(cookieToken); hasher.update("|"); hasher.update(salt); this._csrfFormToken = salt + "|" + hasher.digest('base64'); return this._csrfFormToken; } /** * Verifies a form token submitted with the current request. * This function must be called on the request object. * * @returns {Boolean} True if the form token matches the cookie in the request. */ function verifyFormToken(formToken) { /*jshint validthis:true */ checkSecure(this); // If we already cached this token, we know that it's valid. // If we validate two different tokens for the same request, // this won't incorrectly skip the second one. if (this.res._csrfFormToken && this.res._csrfFormToken === formToken) return true; this._csrfChecked = true; if (!formToken) return false; var cookieToken = getCookieToken(this.res); if (!cookieToken) return false; var parts = formToken.split('|'); // If the token is invalid, reject it. if (parts.length !== 2) return false; var hasher = crypto.createHmac(options.algorithm, formKey); hasher.update(cookieToken); hasher.update("|"); // Don't confuse longer or shorter tokens hasher.update(parts[0]); // If the hash doesn't match, reject the token if (parts[1] !== hasher.digest('base64')) return false; // If we have a valid token, reuse it for this request // instead of generating a new one. (saves crypto ops) if (!this.res._csrfFormToken) this.res._csrfFormToken = formToken; return true; } return function (req, res, next) { res.getFormToken = getFormToken; res.resetCsrf = resetCsrf; req.allowCsrf = allowCsrf; req.verifyToken = verifyFormToken; next(); }; }; function error(code, msg) { var err = new Error(msg || require('http').STATUS_CODES[code]); err.status = code; return err; } function getFormToken(req) { // Copied from connect/csrf return (req.body && req.body._csrf) || (req.query && req.query._csrf) || (req.headers['x-csrf-token']); } var skipMethods = { GET: true, HEAD: true, OPTIONS: true }; function enforcerMiddleware(req, res, next) { if (!req.verifyToken) throw new Error("csrfCrypto.enforcer() must be use()d after csrfCrypto()"); if (skipMethods.hasOwnProperty(req.method)) return next(); // If an earlier middleware calls req.allowCsrf(), don't verify if (req._csrfAllowed) return next(); if (!req.verifyToken(getFormToken(req))) return next(error(403)); next(); } module.exports.enforcer = function () { return enforcerMiddleware; }; function guardMiddleware(req, res, next) { var end = res.end; res.end = function (data, encoding) { if (!skipMethods.hasOwnProperty(req.method) && !(req._csrfAllowed || req._csrfChecked)) throw new Error(req.method + " request to " + req.originalUrl + " finished without CSRF verification"); res.end = end; res.end(data, encoding); }; next(); } module.exports.guard = function () { return guardMiddleware; };