diff --git a/HISTORY.md b/HISTORY.md index 59f844c..07bf2db 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,6 +5,7 @@ unreleased - Fix `maxAge` option to reject invalid values * deps: http-errors@1.8.0 - deps: setprototypeof@1.2.0 + * Allow selection of `csrfTokenPattern`. Support for HMAC based token pattern and encryption based token pattern added. Defaults to double submit cookie pattern. 1.11.0 / 2020-01-18 =================== diff --git a/README.md b/README.md index 7820251..8a6226c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ Node.js [CSRF][wikipedia-csrf] protection middleware. -Requires either a session middleware or [cookie-parser](https://www.npmjs.com/package/cookie-parser) to be initialized first. +If not using stateless CSRF token patterns ([HMAC based token pattern][owasp-csrf-hmac-based-token] or +[Encryption based token pattern][owasp-csrf-encryption-based-token]), it requires either a session middleware +or [cookie-parser](https://www.npmjs.com/package/cookie-parser) to be initialized first. * If you are setting the ["cookie" option](#cookie) to a non-`false` value, then you must use [cookie-parser](https://www.npmjs.com/package/cookie-parser) @@ -40,19 +42,31 @@ var csurf = require('csurf') Create a middleware for CSRF token creation and validation. This middleware adds a `req.csrfToken()` function to make a token which should be added to requests which mutate state, within a hidden form field, query-string etc. -This token is validated against the visitor's session or csrf cookie. #### Options The `csurf` function takes an optional `options` object that may contain any of the following keys: +##### csrfTokenPattern + +Determines the CSRF token pattern to use. +Pass `hmac` for [HMAC based token pattern][owasp-csrf-hmac-based-token]. +Pass `encrypted` for [Encryption based token pattern][owasp-csrf-encryption-based-token] +Defaults to [double submit cookie pattern][owsap-csrf-double-submit]. + +NOTE: In case of HMAC based token pattern, +`req._csrfUserId`, `req._csrfNonce` and `req._csrfOperation` are used to generate the token. Also, `req._csrfUserId` +is used for verifying the token value. + +NOTE: In case of Encryption based token pattern, +`req._csrfUserId` and `req._csrfNonce` are used to generate the token. Also, `req._csrfUserId` +is used for verifying the token value. + ##### cookie Determines if the token secret for the user should be stored in a cookie -or in `req.session`. Storing the token secret in a cookie implements -the [double submit cookie pattern][owsap-csrf-double-submit]. -Defaults to `false`. +or in `req.session`. Defaults to `false`. This option makes no difference in case `csrfTokenPattern` is `hmac` or `encrypted`. When set to `true` (or an object of options for the cookie), then the module changes behavior and no longer uses `req.session`. This means you _are no @@ -93,7 +107,7 @@ Defaults to `'session'` (i.e. looks at `req.session`). The CSRF secret from this library is stored and read as `req[sessionKey].csrfSecret`. If the ["cookie" option](#cookie) is not `false`, then this option does -nothing. +nothing. This option makes no difference in case `csrfTokenPattern` is `hmac` or `encrypted`. ##### value @@ -112,6 +126,18 @@ locations, in order: - `req.headers['x-csrf-token']` - the `X-CSRF-Token` HTTP request header. - `req.headers['x-xsrf-token']` - the `X-XSRF-Token` HTTP request header. +##### hmacSecret + +Applicable only if `csrfTokenPattern` is `hmac`. This is secret known only to the server and is used in the HMAC algorithm. + +##### encryptionKey + +Applicable only if `csrfTokenPattern` is `encrypted`. This is secret key known only to the server and is used in the Encryption algorithm (aes-256-cbc). + +##### expiry + +Applicable only if `csrfTokenPattern` is `hmac` or `encrypted`. This is the token expiry in seconds. Expired tokens are rejected. + ## Example ### Simple express example @@ -306,6 +332,8 @@ app.use(function (err, req, res, next) { [owsap-csrf]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html [owsap-csrf-double-submit]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie +[owasp-csrf-hmac-based-token]: https://owasp.deteact.com/cheat/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern +[owasp-csrf-encryption-based-token]: https://owasp.deteact.com/cheat/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#encryption-based-token-pattern [wikipedia-csrf]: https://en.wikipedia.org/wiki/Cross-site_request_forgery ## License diff --git a/index.js b/index.js index 367bdba..6c50dd1 100644 --- a/index.js +++ b/index.js @@ -16,7 +16,7 @@ var Cookie = require('cookie') var createError = require('http-errors') var sign = require('cookie-signature').sign -var Tokens = require('csrf') +var TokenManagerFactory = require('./tokenManager/Factory') /** * Module exports. @@ -50,8 +50,10 @@ function csurf (options) { // get value getter var value = opts.value || defaultValue + var isStatelessPattern = opts.csrfTokenPattern === 'hmac' || opts.csrfTokenPattern === 'encrypted' + // token repo - var tokens = new Tokens(opts) + var tokenManager = new TokenManagerFactory(opts).getTokenManager() // ignored methods var ignoreMethods = opts.ignoreMethods === undefined @@ -66,18 +68,21 @@ function csurf (options) { var ignoreMethod = getIgnoredMethods(ignoreMethods) return function csrf (req, res, next) { - // validate the configuration against request - if (!verifyConfiguration(req, sessionKey, cookie)) { - return next(new Error('misconfigured csrf')) - } + var token, secret - // get the secret from the request - var secret = getSecret(req, sessionKey, cookie) - var token + if (!isStatelessPattern) { + // validate the configuration against request + if (!verifyConfiguration(req, sessionKey, cookie)) { + return next(new Error('misconfigured csrf')) + } + + // get the secret from the request + secret = getSecret(req, sessionKey, cookie) + } // lazy-load token getter req.csrfToken = function csrfToken () { - var sec = !cookie + var sec = !cookie && !isStatelessPattern ? getSecret(req, sessionKey, cookie) : secret @@ -87,8 +92,8 @@ function csurf (options) { } // generate & set new secret - if (sec === undefined) { - sec = tokens.secretSync() + if (sec === undefined && !isStatelessPattern) { + sec = tokenManager.secretSync() setSecret(req, res, sessionKey, sec, cookie) } @@ -96,19 +101,19 @@ function csurf (options) { secret = sec // create new token - token = tokens.create(secret) + token = tokenManager.create(secret, req) return token } // generate & set secret - if (!secret) { - secret = tokens.secretSync() + if (!secret && !isStatelessPattern) { + secret = tokenManager.secretSync() setSecret(req, res, sessionKey, secret, cookie) } // verify the incoming token - if (!ignoreMethod[req.method] && !tokens.verify(secret, value(req))) { + if (!ignoreMethod[req.method] && !tokenManager.verify(secret, value(req), req)) { return next(createError(403, 'invalid csrf token', { code: 'EBADCSRFTOKEN' })) diff --git a/test/testEncrypted.js b/test/testEncrypted.js new file mode 100644 index 0000000..54af3a4 --- /dev/null +++ b/test/testEncrypted.js @@ -0,0 +1,211 @@ + +process.env.NODE_ENV = 'test' + +var connect = require('connect') +var http = require('http') +var bodyParser = require('body-parser') +var querystring = require('querystring') +var request = require('supertest') + +var csurf = require('..') + +describe('csurf with Encryption based token pattern', function () { + it('should work in req.body', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .send('_csrf=' + encodeURIComponent(token)) + .expect(200, done) + }) + }) +}) + +it('should work in req.query', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/?_csrf=' + encodeURIComponent(token)) + .expect(200, done) + }) +}) + +it('should work in csrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('csrf-token', token) + .expect(200, done) + }) +}) + +it('should work in xsrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('xsrf-token', token) + .expect(200, done) + }) +}) + +it('should work in x-csrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('x-csrf-token', token) + .expect(200, done) + }) +}) + +it('should work in x-xsrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('x-xsrf-token', token) + .expect(200, done) + }) +}) + +it('should fail with an invalid token', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .post('/') + .set('X-CSRF-Token', '42') + .expect(403, done) + }) +}) + +it('should fail with no token', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .post('/') + .expect(403, done) + }) +}) + +it('should fail with expired token', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'encrypted', + encryptionKey: '5e91b89e38697ee924ba9b9940217cf654ecdeed4dc0f551a88c3b7f577469f0', + expiry: 0.5 + }) + + // Token expiry is 0.5 seconds and we use it after 1 second. + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + setTimeout(function () { + request(server) + .post('/') + .set('x-xsrf-token', token) + .expect(403, done) + }, 1000) + }) +}) + +function createServerWithoutCookieAndSession (opts) { + var app = connect() + + app.use(function (req, res, next) { + var index = req.url.indexOf('?') + 1 + + if (index) { + req.query = querystring.parse(req.url.substring(index)) + } + + next() + }) + app.use(bodyParser.urlencoded({ extended: false })) + + app.use(function(req, res, next) { + req._csrfUserId = 123 + req._csrfNonce = 1 + next() + }) + + app.use(csurf(opts)) + + app.use(function (req, res) { + res.end(req.csrfToken() || 'none') + }) + + return http.createServer(app) +} diff --git a/test/testHmac.js b/test/testHmac.js new file mode 100644 index 0000000..4bfbb10 --- /dev/null +++ b/test/testHmac.js @@ -0,0 +1,212 @@ + +process.env.NODE_ENV = 'test' + +var connect = require('connect') +var http = require('http') +var bodyParser = require('body-parser') +var querystring = require('querystring') +var request = require('supertest') + +var csurf = require('..') + +describe('csurf with HMAC based token pattern', function () { + it('should work in req.body', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .send('_csrf=' + encodeURIComponent(token)) + .expect(200, done) + }) + }) +}) + +it('should work in req.query', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/?_csrf=' + encodeURIComponent(token)) + .expect(200, done) + }) +}) + +it('should work in csrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('csrf-token', token) + .expect(200, done) + }) +}) + +it('should work in xsrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('xsrf-token', token) + .expect(200, done) + }) +}) + +it('should work in x-csrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('x-csrf-token', token) + .expect(200, done) + }) +}) + +it('should work in x-xsrf-token header', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + request(server) + .post('/') + .set('x-xsrf-token', token) + .expect(200, done) + }) +}) + +it('should fail with an invalid token', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .post('/') + .set('X-CSRF-Token', '42') + .expect(403, done) + }) +}) + +it('should fail with no token', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1' + }) + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + request(server) + .post('/') + .expect(403, done) + }) +}) + +it('should fail with expired token', function (done) { + var server = createServerWithoutCookieAndSession({ + csrfTokenPattern: 'hmac', + hmacSecret: 'e92633a08116905e4f30eefd1', + expiry: 0.5 + }) + + // Token expiry is 0.5 seconds and we use it after 1 second. + + request(server) + .get('/') + .expect(200, function (err, res) { + if (err) return done(err) + var token = res.text + + setTimeout(function () { + request(server) + .post('/') + .set('x-xsrf-token', token) + .expect(403, done) + }, 1000) + }) +}) + +function createServerWithoutCookieAndSession (opts) { + var app = connect() + + app.use(function (req, res, next) { + var index = req.url.indexOf('?') + 1 + + if (index) { + req.query = querystring.parse(req.url.substring(index)) + } + + next() + }) + app.use(bodyParser.urlencoded({ extended: false })) + + app.use(function(req, res, next) { + req._csrfUserId = 123 + req._csrfNonce = 1 + req._csrfOperation = 'test' + next() + }) + + app.use(csurf(opts)) + + app.use(function (req, res) { + res.end(req.csrfToken() || 'none') + }) + + return http.createServer(app) +} diff --git a/tokenManager/Encrypted.js b/tokenManager/Encrypted.js new file mode 100644 index 0000000..0808046 --- /dev/null +++ b/tokenManager/Encrypted.js @@ -0,0 +1,86 @@ +const Crypto = require('crypto') + +const ALGORITHM = 'aes-256-cbc' +const IV_LENGTH = 16 + +/** + * Encrypted Token Manager + */ +class EncryptedTokenManager { + constructor (opts) { + this.opts = opts + + this.encryptionKey = this.opts.encryptionKey + this.expiry = this.opts.expiry || 60 * 60 // 1 hours default value + } + + /** + * Verify secret + * + * @param {string} secret + * @param {string} token + * @param {IncomingMessage} req + * @returns {boolean} + */ + verify (secret, token, req) { + if (!token) return false + + const encryptionKey = this.encryptionKey + let decrypted + + try { + const [iv, encryptedText] = token.split(':').map(part => Buffer.from(part, 'hex')) + const decipher = Crypto.createDecipheriv(ALGORITHM, Buffer.from(encryptionKey, 'hex'), iv) + decrypted = decipher.update(encryptedText) + decrypted = (Buffer.concat([decrypted, decipher.final()])).toString() + } catch (e) { + return false + } + + const tokenParts = decrypted.split(':') + if (tokenParts.length !== 3) return false + + const csrfUserId = tokenParts[0] + const timestamp = tokenParts[2] + + // Check for token expiry + if (Math.floor(Date.now() / 1000) - timestamp > this.expiry) return false + + // Check for csrfUserId + return this._sanitize(req._csrfUserId) === csrfUserId + } + + /** + * Returns token + * + * @param {String} secret + * @param {IncomingMessage} req + * @return {String} + */ + create (secret, req) { + const encryptionKey = this.encryptionKey + const csrfUserId = this._sanitize(req._csrfUserId) + const csrfNonce = this._sanitize(req._csrfNonce) + + const text = `${csrfUserId}:${csrfNonce}:${Math.floor(Date.now() / 1000)}` + const iv = Crypto.randomBytes(IV_LENGTH) + const cipher = Crypto.createCipheriv(ALGORITHM, Buffer.from(encryptionKey, 'hex'), iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + + return `${iv.toString('hex')}:${encrypted.toString('hex')}` + } + + /** + * Returns sanitized string + * + * @param {string} str + * @returns {string} + * @private + */ + _sanitize (str) { + return `${str}`.replace(/\s+/g, '_').replace(/:+/g, '_') + } +} + +module.exports = EncryptedTokenManager diff --git a/tokenManager/Factory.js b/tokenManager/Factory.js new file mode 100644 index 0000000..b199865 --- /dev/null +++ b/tokenManager/Factory.js @@ -0,0 +1,28 @@ +const DoubleSubmitTokenManager = require('csrf') +const HmacTokenManager = require('./Hmac') +const EncryptedTokenManager = require('./Encrypted') + +/** + * Token Manager Factory class for providing token manager object based on CSRF token pattern + */ +class TokenManagerFactory { + constructor (opts) { + this.opts = opts || {} + this.csrfTokenPattern = this.opts.csrfTokenPattern + } + + /** + * Get token manager object + */ + getTokenManager () { + if (this.csrfTokenPattern === 'hmac') { + return new HmacTokenManager(this.opts) + } else if (this.csrfTokenPattern === 'encrypted') { + return new EncryptedTokenManager(this.opts) + } else { // If not passed, treat double submit token manager as default. + return new DoubleSubmitTokenManager(this.opts) + } + } +} + +module.exports = TokenManagerFactory diff --git a/tokenManager/Hmac.js b/tokenManager/Hmac.js new file mode 100644 index 0000000..ff4499c --- /dev/null +++ b/tokenManager/Hmac.js @@ -0,0 +1,75 @@ +const Crypto = require('crypto') + +/** + * HMAC Token Manager + */ +class HmacTokenManager { + constructor (opts) { + this.opts = opts + + this.hmacSecret = this.opts.hmacSecret + this.expiry = this.opts.expiry || 60 * 60 // 1 hours default value + } + + /** + * Verify secret + * + * @param {string} secret + * @param {string} token + * @param {IncomingMessage} req + * @returns {boolean} + */ + verify (secret, token, req) { + if (!token) return false + + const tokenParts = token.split(':') + if (tokenParts.length !== 5) return false + + const csrfUserId = tokenParts[0] + const csrfNonce = tokenParts[1] + const csrfOperation = tokenParts[2] + const timestamp = tokenParts[3] + + // Check for token expiry + if (Math.floor(Date.now() / 1000) - timestamp > this.expiry) return false + + // Check for csrfUserId + if (this._sanitize(req._csrfUserId) !== csrfUserId) return false + + return this.create('dummy', { + _csrfUserId: csrfUserId, + _csrfNonce: csrfNonce, + _csrfOperation: csrfOperation + }, timestamp) === token + } + + /** + * Returns token + * + * @param {String} secret + * @param {IncomingMessage} req + * @param {number} timestamp + * @return {String} + */ + create (secret, req, timestamp = Math.floor(Date.now() / 1000)) { + const csrfUserId = this._sanitize(req._csrfUserId) + const csrfNonce = this._sanitize(req._csrfNonce) + const csrfOperation = this._sanitize(req._csrfOperation) + + const prefix = `${csrfUserId}:${csrfNonce}:${csrfOperation}:${timestamp}` + return prefix + ':' + Crypto.createHmac('sha256', this.hmacSecret).update(prefix).digest('hex') + } + + /** + * Returns sanitized string + * + * @param {string} str + * @returns {string} + * @private + */ + _sanitize (str) { + return `${str}`.replace(/\s+/g, '_').replace(/:+/g, '_') + } +} + +module.exports = HmacTokenManager