diff --git a/API.md b/API.md index c93d45f..22711d7 100644 --- a/API.md +++ b/API.md @@ -63,7 +63,8 @@ The following options are available when registering the plugin. * `skip` - a function with the signature of `function (request, h) {}`, which when provided, is called for every request. If the provided function returns true, validation and generation of crumb is skipped. Defaults to `false`. * `enforce` - defaults to true, using enforce with false will set the CSRF header cookie but won't execute the validation * `logUnauthorized` - whether to add to the request log with tag 'crumb' and data 'validation failed' (defaults to false) - + * `method` - The token generation method (and therefore how to validate), could take values `random` or `hmac`. Defaults to `random`, if set to `hmac` crumb will use the `Hmac Based Token pattern` [as described by OWASP here](https://github.com/OWASP/CheatSheetSeries/blob/083b4791c44ef28b105f66c82ffd83a86bf9fa16/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md). + * `sessionKey` - Define which key of `auth.credentials` should be used during token creation if specified method is `hmac` ### Routes configuration Additionally, some configuration can be passed on a per-route basis. Disable Crumb for a particular route by passing `false` instead of a configuration object. diff --git a/lib/hmac.js b/lib/hmac.js new file mode 100644 index 0000000..bec635b --- /dev/null +++ b/lib/hmac.js @@ -0,0 +1,56 @@ +'use strict'; + +const Crypto = require('crypto'); + +const ALGO = 'sha256'; +/** + * Returns base64-encoded ciphertext + * @param {string}userId The string to encrypt + * @param {string}key The secret + * @returns {string}token + */ +const encrypt = (userId, key) => { + + const timestamp = Date.now().toString(); + const digest = Crypto.createHmac(ALGO, key) + .update(userId, 'utf8') + .update(timestamp, 'utf8') + .digest('hex'); + + return `${digest}_${timestamp}`; +}; + +/** + * Validate a CSRF token generated with HMAC method. based on OWASP cheatsheet + * https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern + * @param {string}enc + * @param {string}userId + * @param {string}key + */ +const validate = (enc, userId, key) => { + + if (!enc || !userId || !key) { + // Validation fails is one of the params is null or undefined + return false; + } + + const timestamp = Date.now().toString(); + const [token_digest, token_timestamp] = enc.split('_'); //Split the encrypted string to retrieve the hmac digest and the timestamp + + if (!token_digest || !token_timestamp) { + // Validation fails is one of the params is null or undefined + return false; + } + + const digest = Crypto.createHmac(ALGO, key) + .update(userId, 'utf8') + .update(token_timestamp, 'utf8') // Timestamp used at token generation + .digest('hex'); + + return (digest === token_digest && token_timestamp <= timestamp); +}; + +module.exports = { + encrypt, + validate +}; diff --git a/lib/index.js b/lib/index.js index b17a29f..de40fcb 100755 --- a/lib/index.js +++ b/lib/index.js @@ -7,6 +7,7 @@ const Cryptiles = require('@hapi/cryptiles'); const Hoek = require('@hapi/hoek'); const Validate = require('@hapi/validate'); +const Hmac = require('./hmac'); const internals = { restfulValidatedMethods: ['post', 'put', 'patch', 'delete'] @@ -23,7 +24,16 @@ internals.schema = Validate.object().keys({ restful: Validate.boolean().optional(), skip: Validate.func().optional(), enforce: Validate.boolean().optional(), - logUnauthorized: Validate.boolean().optional() + logUnauthorized: Validate.boolean().optional(), + method: Validate.string().optional().valid('random', 'hmac'), + secret: Validate.string().when( + 'method', + { is: Validate.exist(), then: Validate.required(), otherwise: Validate.optional() } + ), + sessionKey: Validate.string().when( + 'method', + { is: Validate.exist(), then: Validate.required(), otherwise: Validate.optional() } + ) }); @@ -39,7 +49,9 @@ internals.defaults = { restful: false, // Set to true for custom header crumb validation. Disables payload/query validation skip: false, // Set to a function which returns true when to skip crumb generation and validation, enforce: true, // Set to true for setting the CSRF cookie while not performing validation - logUnauthorized: false // Set to true for crumb to write an event to the request log + logUnauthorized: false, // Set to true for crumb to write an event to the request log + method: 'random', // Define the token generation method (and therefore how to validate) + sessionKey: 'userId' // Define which key of auth.credentials should be used during token creation if method = hmac }; @@ -68,7 +80,8 @@ exports.plugin = { const unauthorizedLogger = () => { if (settings.logUnauthorized) { - request.log(['crumb', 'unauthorized'], 'validation failed'); + const tags = ['crumb', 'unauthorized']; + request.log(tags, 'validation failed'); } }; @@ -130,7 +143,6 @@ exports.plugin = { } // Validate crumb - const restful = request.route.settings.plugins._crumb ? request.route.settings.plugins._crumb.restful : settings.restful; if (restful) { if (!internals.restfulValidatedMethods.includes(request.method) || !request.route.settings.plugins._crumb) { @@ -144,7 +156,13 @@ exports.plugin = { throw Boom.forbidden(); } - if (header !== getCrumbValue()) { + if (settings.method !== 'hmac' && header !== getCrumbValue()) { + unauthorizedLogger(); + throw Boom.forbidden(); + } + + if (settings.method === 'hmac' && + !Hmac.validate(header, request.auth.credentials[settings.sessionKey], settings.secret)) { unauthorizedLogger(); throw Boom.forbidden(); } @@ -168,11 +186,17 @@ exports.plugin = { throw Boom.forbidden(); } - if (content[request.route.settings.plugins._crumb.key] !== getCrumbValue()) { + if (settings.method !== 'hmac' && content[request.route.settings.plugins._crumb.key] !== getCrumbValue()) { unauthorizedLogger(); throw Boom.forbidden(); } + if (settings.method === 'hmac' && + !Hmac.validate(content[request.route.settings.plugins._crumb.key], request.auth.credentials[settings.sessionKey], settings.secret) + ) { + unauthorizedLogger(); + throw Boom.forbidden(); + } // Remove crumb delete request[request.route.settings.plugins._crumb.source][request.route.settings.plugins._crumb.key]; @@ -210,7 +234,16 @@ exports.plugin = { const generate = function (request, h) { if (!request.plugins.crumb) { - const crumb = Cryptiles.randomString(settings.size); + let crumb = null; + + if (settings.method === 'random') { + crumb = Cryptiles.randomString(settings.size); + } + + if (settings.method === 'hmac' && (request.auth.isAuthenticated)) { + crumb = Hmac.encrypt(request.auth.credentials[settings.sessionKey], settings.secret); + } + h.state(settings.key, crumb, settings.cookieOptions); request.plugins.crumb = crumb; } diff --git a/test/hmac.js b/test/hmac.js new file mode 100644 index 0000000..0e3d7e0 --- /dev/null +++ b/test/hmac.js @@ -0,0 +1,66 @@ +'use strict'; + +const Code = require('@hapi/code'); +const Lab = require('@hapi/lab'); +const Hmac = require('../lib/hmac'); + +const { describe, it } = exports.lab = Lab.script(); +const { expect } = Code; + +describe('Hmac', () => { + + const mySecret = 'super secret!!! Do not share'; + const userId = 'myUserId'; + + it('should export two methods', () => { + + expect(Hmac).to.include('encrypt') + .and + .to.include('validate'); + + expect(Hmac.encrypt).to.be.a.function(); + expect(Hmac.validate).to.be.a.function(); + }); + + describe('encrypt', () => { + + it('should return a digest and a timestamp concatenate with _', () => { + + const result = Hmac.encrypt(userId, mySecret); + expect(result).to.be.a.string().and.to.include('_'); + }); + }); + + describe('validate', () => { + + it('should return FALSE if the token is not in the right format', () => { + + let encryptedStr = 'thisIsNotaGoodformat'; + expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.false(); + + encryptedStr = '_thisIsNotaGoodformat'; + expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.false(); + }); + + it('should return FALSE if one of the parameters is null', () => { + + const encryptedStr = 'thisIsNotaGoodformat'; + + expect(Hmac.validate(null, userId, mySecret)).to.be.a.false(); + expect(Hmac.validate(encryptedStr, null, mySecret)).to.be.a.false(); + expect(Hmac.validate(encryptedStr, userId, null)).to.be.a.false(); + }); + + it('should return TRUE if the token is valid', () => { + + const encryptedStr = Hmac.encrypt(userId, mySecret); + expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.true(); + }); + + it('should return FALSE if digest do not match', () => { + + const encryptedStr = `123456ABCD_${Date.now().toString()}`; + expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.false(); + }); + }); +}); diff --git a/test/index_hmac.js b/test/index_hmac.js new file mode 100644 index 0000000..8a58bd9 --- /dev/null +++ b/test/index_hmac.js @@ -0,0 +1,1391 @@ +'use strict'; + +const Stream = require('stream'); + +const Code = require('@hapi/code'); +const Crumb = require('..'); +const Hapi = require('@hapi/hapi'); +const Lab = require('@hapi/lab'); +const Vision = require('@hapi/vision'); + +const TLSCert = require('./fixtures/cert'); +const Views = require('./fixtures/views'); + + +const internals = {}; + + +const { describe, it, beforeEach } = exports.lab = Lab.script(); +const { expect } = Code; + +internals.viewOptions = { + path: __dirname + '/templates', + engines: { + html: require('handlebars') + } +}; + +describe('Crumb', () => { + + let server = null; + + beforeEach(() => { + + server = new Hapi.Server(); + server.ext({ + type: 'onPostAuth', + method: (req, h) => { + // This extension method fakes an authentication + req.auth.credentials = { userId: 'john' }; + req.auth.isAuthenticated = true; + return h.continue; + }, + options: { + before: 'crumb' + } + + }); + }); + + it('returns view with crumb', async () => { + + server.route([ + { + method: 'GET', + path: '/1', + handler: (request, h) => { + + expect(request.plugins.crumb).to.exist(); + expect(request.server.plugins.crumb.generate).to.exist(); + + return h.view('index', { + title: 'test', + message: 'hi' + }); + } + }, + { + method: 'POST', + path: '/2', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'POST', + path: '/3', + options: { + payload: { + output: 'stream' + } + }, + handler: (request, h) => 'never' + }, + { + method: 'GET', + path: '/4', + options: { + plugins: { + crumb: false + } + }, + handler: (request, h) => { + + return h.view('index', { + title: 'test', + message: 'hi' + }); + } + }, + { + method: 'POST', + path: '/5', + options: { + payload: { + output: 'stream' + } + }, + handler: (request, h) => 'yo' + }, + { + method: 'GET', + path: '/6', + handler: (request, h) => h.view('index') + }, + { + method: 'GET', + path: '/7', + handler: (request, h) => h.redirect('/1') + } + ]); + + await server.register([ + Vision, + { + plugin: Crumb, + options: { + cookieOptions: { + isSecure: true + }, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + server.views(internals.viewOptions); + + // Works with get requests + const res = await server.inject({ + method: 'GET', + url: '/1' + }); + + expect(res.statusCode).to.equal(200); + + const header = res.headers['set-cookie']; + + expect(header.length).to.equal(1); + expect(header[0]).to.contain('Secure'); + + const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); + + expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); + + // Works with crumb on POST body request + const res2 = await server.inject({ + method: 'POST', + url: '/2', + payload: '{ "key": "value", "crumb": "' + cookie[1] + '" }', + headers: { + cookie: 'crumb=' + cookie[1] + } + }); + + expect(res2.result).to.equal('valid'); + + // Rejects on invalid crumb on POST body request + const res3 = await server.inject({ + method: 'POST', + url: '/2', + payload: '{ "key": "value", "crumb": "x' + cookie[1] + '" }', + headers: { + cookie: 'crumb=' + cookie[1] + } + }); + + expect(res3.statusCode).to.equal(403); + + // Rejects on missing crumb on POST stream body requests + const res4 = await server.inject({ + method: 'POST', + url: '/3', + headers: { + cookie: 'crumb=' + cookie[1] + } + }); + + expect(res4.statusCode).to.equal(403); + + // Works with crumb generation disabled + const res5 = await server.inject({ + method: 'GET', + url: '/4' + }); + + expect(res5.result).to.equal(Views.viewWithoutCrumb()); + + // Works on POST stream requests + + + const TestStream = class extends Stream.Readable { + + constructor(options) { + + super(options); + this._max = 2; + this._index = 1; + } + + _read() { + + const i = this._index++; + if (i > this._max) { + this.push(null); + } + else { + const str = '' + i; + const buf = Buffer.from(str, 'ascii'); + this.push(buf); + } + } + }; + + const res6 = await server.inject({ + method: 'POST', + url: '/5', + payload: new TestStream(), + headers: { + 'content-type': 'application/octet-stream', + 'content-disposition': 'attachment; filename="test.txt"' + }, + simulate: { + end: true + } + }); + + expect(res6.statusCode).to.equal(403); + + // Works with get requests with no context + const res7 = await server.inject({ + method: 'GET', + url: '/6' + }); + + const header2 = res7.headers['set-cookie']; + expect(header2.length).to.equal(1); + expect(header2[0]).to.contain('Secure'); + + const cookie2 = header2[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); + expect(res7.result).to.equal(Views.viewWithCrumbAndNoContext(cookie2[1])); + + // Works with redirections + const res8 = await server.inject({ + method: 'GET', + url: '/7' + }); + + const cookie3 = res8.headers['set-cookie'].toString(); + expect(cookie3).to.contain('crumb'); + + const headers = {}; + headers.origin = 'http://127.0.0.1'; + + // Works with cross-origin enabled requests + const res9 = await server.inject({ + method: 'GET', + url: '/1', + headers + }); + + const cookie4 = res9.headers['set-cookie'].toString(); + expect(cookie4).to.contain('crumb'); + }); + + it('Does not add crumb to view context when "addToViewContext" option set to false', async () => { + + server.route({ + method: 'GET', + path: '/1', + handler: (request, h) => { + + expect(request.plugins.crumb).to.exist(); + expect(request.server.plugins.crumb.generate).to.exist(); + + return h.view('index', { + title: 'test', + message: 'hi' + }); + } + }); + + const plugins = [ + Vision, + { + plugin: Crumb, + options: { + cookieOptions: { + isSecure: true + }, + addToViewContext: false, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]; + + await server.register(plugins); + server.views(internals.viewOptions); + + const res = await server.inject({ + method: 'GET', + url: '/1' + }); + + expect(res.result).to.equal(Views.viewWithoutCrumb()); + }); + + it('Works without specifying plugin options', async () => { + + server.route({ + method: 'GET', + path: '/1', + handler: (request, h) => { + + expect(request.plugins.crumb).to.exist(); + expect(request.server.plugins.crumb.generate).to.exist(); + + return h.view('index', { + title: 'test', + message: 'hi' + }); + } + }); + + await server.register([Vision, { + plugin: Crumb, + options: { + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + }]); + + server.views(internals.viewOptions); + + const res = await server.inject({ + method: 'GET', + url: '/1' + }); + + const header = res.headers['set-cookie']; + expect(header.length).to.equal(1); + + const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); + expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); + }); + + it('Adds to the request log if plugin option logUnauthorized is set to true', async () => { + + let logFound; + const preResponse = function (request, h) { + + const logs = request.logs; + logFound = logs.find((log) => { + + return log.tags[0] === 'crumb' && log.data === 'validation failed'; + }); + + return h.continue; + }; + + server.ext('onPreResponse', preResponse); + + server.route({ + method: 'POST', + path: '/1', + config: { + log: { + collect: true + } + }, + handler: (request, h) => 'test' + }); + + await server.register([ + { + plugin: Crumb, + options: { + logUnauthorized: true, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + const headers = {}; + headers['X-API-Token'] = 'test'; + + await server.inject({ + method: 'POST', + url: '/1', + payload: '{ "key": true }', + headers + }); + expect(logFound).to.exist(); + }); + + it('Does not add to the request log if plugin option logUnauthorized is set to false', async () => { + + let logFound; + const preResponse = function (request, h) { + + const logs = request.logs; + logFound = logs.find((log) => { + + return log.tags[0] === 'crumb' && log.data === 'validation failed'; + }); + + return h.continue; + }; + + server.ext('onPreResponse', preResponse); + + server.route({ + method: 'POST', + path: '/1', + config: { + log: { + collect: true + } + }, + handler: (request, h) => 'test' + }); + + await server.register([ + { + plugin: Crumb, + options: { + logUnauthorized: false, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + const headers = {}; + headers['X-API-Token'] = 'test'; + + await server.inject({ + method: 'POST', + url: '/1', + headers + }); + + expect(logFound).to.not.exist(); + }); + + it('should fail to register with bad options', async () => { + + try { + await server.register({ + plugin: Crumb, + options: { + method: 'new', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + }); + } + catch (err) { + expect(err).to.exist(); + expect(err.name).to.equal('ValidationError'); + // TODO: Message validation fails because of formatting produced by assert :( + // expect(err.message).to.equal('"foo" is not allowed'); + } + }); + + it('route uses crumb when route.options.plugins.crumb set to true and autoGenerate set to false', async () => { + + server.route([ + { + method: 'GET', + path: '/1', + handler: (request, h) => { + + const crumb = request.plugins.crumb; + + expect(crumb).to.not.exist(); + + return 'bonjour'; + } + }, + { + method: 'GET', + path: '/2', + options: { + plugins: { + crumb: true + } + }, + handler: (request, h) => 'hola' + } + ]); + + await server.register([ + Vision, + { + plugin: Crumb, + options: { + autoGenerate: false, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + server.views(internals.viewOptions); + + await server.inject({ + method: 'GET', + url: '/1' + }); + + const res = await server.inject({ method: 'GET', url: '/2' }); + + const header = res.headers['set-cookie']; + expect(header.length).to.equal(1); + }); + + it('route should still validate crumb when autoGenerate is false and route.options.plugins.crumb is not defined', async () => { + + server.route([ + { + method: 'POST', + path: '/1', + handler: (request, h) => { + + return 'bonjour'; + } + } + ]); + + await server.register([ + Vision, + { + plugin: Crumb, + options: { + autoGenerate: false, + restful: true, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + server.views(internals.viewOptions); + + let res = await server.inject({ method: 'POST', url: '/1' }); + expect(res.statusCode).to.equal(403); + + const Hmac = require('../lib/hmac'); + + const crumbValue = Hmac.encrypt('john', 'SuperSecretKey_do_not_share!!!'); + res = await server.inject({ + method: 'POST', + url: '/1', + headers: { + cookie: `crumb=${crumbValue}`, + 'X-CSRF-token': crumbValue + } + }); + expect(res.statusCode).to.equal(200); + }); + + it('fails validation when no payload provided and not using restful mode', async () => { + + server.route({ + method: 'POST', + path: '/1', + handler: (request, h) => 'test' + }); + + await server.register([{ + plugin: Crumb, options: { + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + }]); + + const headers = {}; + headers['X-API-Token'] = 'test'; + + const res = await server.inject({ + method: 'POST', + url: '/1', + headers + }); + + expect(res.statusCode).to.equal(403); + }); + + it('does not validate crumb when "skip" option returns true', async () => { + + server.route({ + method: 'POST', + path: '/1', + handler: (request, h) => 'test' + }); + + const skip = (request) => request.headers['x-api-token'] === 'test'; + + const plugins = [ + { + plugin: Crumb, + options: { + skip, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]; + + await server.register(plugins); + + const headers = { + 'X-API-Token': 'test' + }; + + const res = await server.inject({ + method: 'POST', + url: '/1', + headers + }); + + const header = res.headers['set-cookie']; + + expect(res.statusCode).to.equal(200); + expect(header).to.not.exist(); + }); + + it('validates crumb when "skip" option returns false', async () => { + + server.route({ + method: 'POST', + path: '/1', + handler: (request, h) => 'test' + }); + + const skip = (request) => false; + + const plugins = [ + { + plugin: Crumb, + options: { + skip, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]; + + await server.register(plugins); + + const headers = { + 'X-API-Token': 'test' + }; + + const res = await server.inject({ + method: 'POST', + url: '/1', + headers + }); + + expect(res.statusCode).to.equal(403); + }); + + it('ensures crumb "skip" option is a function', async () => { + + server.route({ + method: 'POST', + path: '/1', + handler: (request, h) => 'test' + }); + + const skip = true; + + try { + await server.register([ + Vision, + { + plugin: Crumb, + options: { + skip, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + } + catch (err) { + expect(err).to.exist(); + } + }); + + it('does not set crumb cookie insecurely', async () => { + + server = new Hapi.Server({ + host: 'localhost', + port: 80, + routes: { + cors: true + } + }); + + server.route({ + method: 'GET', + path: '/1', + options: { + cors: false + }, + handler: (request, h) => 'test' + }); + + server.route({ + method: 'GET', + path: '/2', + handler: (request, h) => 'test' + }); + + server.route({ + method: 'GET', + path: '/3', + options: { + cors: { + origin: ['http://127.0.0.1'] + } + }, + handler: (request, h) => 'test' + }); + + await server.register({ + plugin: Crumb, options: { + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + }); + + const headers = {}; + + const res = await server.inject({ + method: 'GET', + url: '/1', + headers + }); + + const header = res.headers['set-cookie']; + expect(header[0]).to.contain('crumb'); + + headers.origin = 'http://localhost'; + + const res2 = await server.inject({ + method: 'GET', + url: '/2', + headers + }); + + const header2 = res2.headers['set-cookie']; + expect(header2[0]).to.contain('crumb'); + + headers.origin = 'http://127.0.0.1'; + + const res3 = await server.inject({ + method: 'GET', + url: '/3', + headers + }); + + const header3 = res3.headers['set-cookie']; + + expect(header3[0]).to.contain('crumb'); + + const res4 = await server.inject({ + method: 'GET', + url: '/3' + }); + + const header4 = res4.headers['set-cookie']; + + expect(header4[0]).to.contain('crumb'); + + headers.origin = 'http://badsite.com'; + + const res5 = await server.inject({ + method: 'GET', + url: '/3', + headers + }); + + const header5 = res5.headers['set-cookie']; + expect(header5).to.not.exist(); + }); + + it('does not set crumb cookie insecurely using https', async () => { + + const options = { + host: 'localhost', + port: 443, + tls: TLSCert + }; + + server = new Hapi.Server(options); + + server.route([ + { + method: 'GET', + path: '/1', + handler: (request, h) => 'test' + } + ]); + + await server.register({ + plugin: Crumb, options: { + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + }); + + const res = await server.inject({ + method: 'GET', + url: '/1', + headers: { + host: 'localhost:443' + } + }); + + const header = res.headers['set-cookie']; + expect(header[0]).to.contain('crumb'); + }); + + it('validates crumb with X-CSRF-Token header', async () => { + + server.route([ + { + method: 'GET', + path: '/1', + handler: (request, h) => { + + expect(request.plugins.crumb).to.exist(); + expect(request.server.plugins.crumb.generate).to.exist(); + + return h.view('index', { + title: 'test', + message: 'hi' + }); + } + }, + { + method: 'POST', + path: '/2', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'POST', + path: '/3', + options: { payload: { output: 'stream' } }, + handler: (request, h) => 'never' + }, + { + method: 'PUT', + path: '/4', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'PATCH', + path: '/5', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'DELETE', + path: '/6', + handler: (request, h) => 'valid' + }, + { + method: 'POST', + path: '/7', + options: { + plugins: { + crumb: false + } + }, + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'POST', + path: '/8', + options: { + plugins: { + crumb: { + restful: false, + source: 'payload' + } + } + }, + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + } + + ]); + + await server.register([ + Vision, + { + plugin: Crumb, + options: { + restful: true, + cookieOptions: { + isSecure: true + }, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + server.views(internals.viewOptions); + + const res = await server.inject({ + method: 'GET', + url: '/1' + }); + + const header = res.headers['set-cookie']; + expect(header.length).to.equal(1); + expect(header[0]).to.contain('Secure'); + + const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); + + const validHeader = { + cookie: 'crumb=' + cookie[1], + 'x-csrf-token': cookie[1] + }; + + const invalidHeader = { + cookie: 'crumb=' + cookie[1], + 'x-csrf-token': 'x' + cookie[1] + }; + + expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); + + const res2 = await server.inject({ + method: 'POST', + url: '/2', + payload: '{ "key": "value" }', + headers: validHeader + }); + + expect(res2.result).to.equal('valid'); + + const res3 = await server.inject({ + method: 'POST', + url: '/2', + payload: '{ "key": "value" }', + headers: invalidHeader + }); + + expect(res3.statusCode).to.equal(403); + + const res4 = await server.inject({ + method: 'POST', + url: '/3', + headers: { + cookie: 'crumb=' + cookie[1] + } + }); + + expect(res4.statusCode).to.equal(403); + + const res5 = await server.inject({ + method: 'PUT', + url: '/4', + payload: '{ "key": "value" }', + headers: validHeader + }); + + expect(res5.result).to.equal('valid'); + + const res6 = await server.inject({ + method: 'PUT', + url: '/4', + payload: '{ "key": "value" }', + headers: invalidHeader + }); + + expect(res6.statusCode).to.equal(403); + + const res7 = await server.inject({ + method: 'PATCH', + url: '/5', + payload: '{ "key": "value" }', + headers: validHeader + }); + + expect(res7.result).to.equal('valid'); + + const res8 = await server.inject({ + method: 'PATCH', + url: '/5', + payload: '{ "key": "value" }', + headers: invalidHeader + }); + + expect(res8.statusCode).to.equal(403); + + const res9 = await server.inject({ + method: 'DELETE', + url: '/6', + headers: validHeader + }); + + expect(res9.result).to.equal('valid'); + + const res10 = await server.inject({ + method: 'DELETE', + url: '/6', + headers: invalidHeader + }); + + expect(res10.statusCode).to.equal(403); + + const res11 = await server.inject({ + method: 'POST', + url: '/7', + payload: '{ "key": "value" }' + }); + + expect(res11.result).to.equal('valid'); + + const payload = { key: 'value', crumb: cookie[1] }; + + delete validHeader['x-csrf-token']; + + const res12 = await server.inject({ + method: 'POST', + url: '/8', + payload: JSON.stringify(payload), + headers: validHeader + }); + + expect(res12.statusCode).to.equal(200); + }); + + it('validates crumb with a custom header name', async () => { + + server.route([ + { + method: 'GET', + path: '/1', + handler: (request, h) => { + + expect(request.plugins.crumb).to.exist(); + expect(request.server.plugins.crumb.generate).to.exist(); + + return h.view('index', { + title: 'test', + message: 'hi' + }); + } + }, + { + method: 'POST', + path: '/2', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'POST', + path: '/3', + options: { payload: { output: 'stream' } }, + handler: (request, h) => 'never' + }, + { + method: 'PUT', + path: '/4', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'PATCH', + path: '/5', + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'DELETE', + path: '/6', + handler: (request, h) => 'valid' + }, + { + method: 'POST', + path: '/7', + options: { + plugins: { + crumb: false + } + }, + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + }, + { + method: 'POST', + path: '/8', + options: { + plugins: { + crumb: { + restful: false, + source: 'payload' + } + } + }, + handler: (request, h) => { + + expect(request.payload).to.equal({ key: 'value' }); + return 'valid'; + } + } + + ]); + + await server.register([ + Vision, + { + plugin: Crumb, + options: { + restful: true, + cookieOptions: { + isSecure: true + }, + headerName: 'X-CUSTOM-TOKEN', + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + server.views(internals.viewOptions); + + const res = await server.inject({ + method: 'GET', + url: '/1' + }); + + const header = res.headers['set-cookie']; + expect(header.length).to.equal(1); + expect(header[0]).to.contain('Secure'); + + const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); + + const validHeader = { + cookie: 'crumb=' + cookie[1], + 'x-custom-token': cookie[1] + }; + + const invalidHeader = { + cookie: 'crumb=' + cookie[1], + 'x-custom-token': 'x' + cookie[1] + }; + + expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); + + const res2 = await server.inject({ + method: 'POST', + url: '/2', + payload: '{ "key": "value" }', + headers: validHeader + }); + + expect(res2.result).to.equal('valid'); + + const res3 = await server.inject({ + method: 'POST', + url: '/2', + payload: '{ "key": "value" }', + headers: invalidHeader + }); + + expect(res3.statusCode).to.equal(403); + + const res4 = await server.inject({ + method: 'POST', + url: '/3', + headers: { + cookie: 'crumb=' + cookie[1] + } + }); + + expect(res4.statusCode).to.equal(403); + + const res5 = await server.inject({ + method: 'PUT', + url: '/4', + payload: '{ "key": "value" }', + headers: validHeader + }); + + expect(res5.result).to.equal('valid'); + + const res6 = await server.inject({ + method: 'PUT', + url: '/4', + payload: '{ "key": "value" }', + headers: invalidHeader + }); + + expect(res6.statusCode).to.equal(403); + + const res7 = await server.inject({ + method: 'PATCH', + url: '/5', + payload: '{ "key": "value" }', + headers: validHeader + }); + + expect(res7.result).to.equal('valid'); + + const res8 = await server.inject({ + method: 'PATCH', + url: '/5', + payload: '{ "key": "value" }', + headers: invalidHeader + }); + + expect(res8.statusCode).to.equal(403); + + const res9 = await server.inject({ + method: 'DELETE', + url: '/6', + headers: validHeader + }); + + expect(res9.result).to.equal('valid'); + + const res10 = await server.inject({ + method: 'DELETE', + url: '/6', + headers: invalidHeader + }); + + expect(res10.statusCode).to.equal(403); + + const res11 = await server.inject({ + method: 'POST', + url: '/7', + payload: '{ "key": "value" }' + }); + + expect(res11.result).to.equal('valid'); + + const payload = { key: 'value', crumb: cookie[1] }; + + delete validHeader['x-custom-token']; + + const res12 = await server.inject({ + method: 'POST', + url: '/8', + payload: JSON.stringify(payload), + headers: validHeader + }); + + expect(res12.statusCode).to.equal(200); + }); + + it('should set cookie but ignore check with enforce flag turned off', async () => { + + server.route({ + method: 'POST', + path: '/1', + handler: (request, h) => 'test' + }); + + const plugins = [ + { + plugin: Crumb, + options: { + enforce: false, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]; + + await server.register(plugins); + + const headers = { + 'X-API-Token': 'test' + }; + + const res = await server.inject({ + method: 'POST', + url: '/1', + headers + }); + + const header = res.headers['set-cookie']; + + expect(header).to.exist(); + expect(res.statusCode).to.equal(200); + }); + + it('should fails validation when crumb has been forged', async () => { + + await server.register([ + { + plugin: Crumb, + options: { + cookieOptions: { + isSecure: true + }, + method: 'hmac', + secret: 'SuperSecretKey_do_not_share!!!', + sessionKey: 'userId' + } + } + ]); + + server.route({ + method: 'POST', + path: '/1', + handler: (request, h) => 'test' + }); + + const headers = { + 'X-CSRF-Token': 'ThisIsAForgedToken_1607110465663' + }; + + const res = await server.inject({ + method: 'POST', + url: '/1', + headers + }); + + expect(res.statusCode).to.equal(403); + }); +});