csrf-crypto
Version:
Connect middleware for session-less CSRF protection using cryptography.
259 lines (205 loc) • 8.16 kB
JavaScript
"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; };