├── test ├── templates │ └── index.html ├── fixtures │ ├── views.js │ └── cert.json ├── esm.js └── index.js ├── .gitignore ├── example ├── templates │ ├── message.html │ └── index.html ├── package.json ├── server.js └── restful.js ├── .github └── workflows │ └── ci-plugin.yml ├── package.json ├── README.md ├── LICENSE.md ├── API.md └── lib └── index.js /test/templates/index.html: -------------------------------------------------------------------------------- 1 | {{title}}

{{message}}

{{crumb}}

-------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | 11 | **/.vs 12 | **/.vscode 13 | **/.idea 14 | -------------------------------------------------------------------------------- /example/templates/message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 6 | 7 |
8 |

Your message: {{message}}

9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/ci-plugin.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | uses: hapijs/.github/.github/workflows/ci-plugin.yml@master 13 | with: 14 | min-node-version: 14 15 | min-hapi-version: 20 16 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "restful.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "start": "node server.js", 8 | "start:restful": "node restful.js" 9 | }, 10 | "devDependencies": { 11 | "@hapi/hapi": "20.x.x", 12 | "@hapi/vision": "5.x.x", 13 | "handlebars": "4.x.x" 14 | }, 15 | "license": "BSD-3-Clause" 16 | } 17 | -------------------------------------------------------------------------------- /test/fixtures/views.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | viewWithoutCrumb: () => 'test

hi

', 5 | viewWithCrumb: (crumb) => 'test

hi

' + crumb + '

', 6 | viewWithCrumbAndNoContext: (crumb) => '

' + crumb + '

' 7 | }; 8 | -------------------------------------------------------------------------------- /test/esm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Lab = require('@hapi/lab'); 5 | 6 | 7 | const { before, describe, it } = exports.lab = Lab.script(); 8 | const expect = Code.expect; 9 | 10 | 11 | describe('import()', () => { 12 | 13 | let Crumb; 14 | 15 | before(async () => { 16 | 17 | Crumb = await import('../lib/index.js'); 18 | }); 19 | 20 | it('exposes all methods and classes as named imports', () => { 21 | 22 | expect(Object.keys(Crumb)).to.equal([ 23 | 'default', 24 | 'plugin' 25 | ]); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 6 | 7 |
8 |

This is a valid form

9 |
10 | 11 | 12 | 13 |
14 |
15 |
16 |

This is an invalid form

17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hapi/crumb", 3 | "description": "CSRF crumb generation and validation plugin", 4 | "version": "9.0.1", 5 | "repository": "git://github.com/hapijs/crumb", 6 | "main": "lib/index.js", 7 | "files": [ 8 | "lib" 9 | ], 10 | "keywords": [ 11 | "hapi", 12 | "plugin", 13 | "cookies", 14 | "csrf", 15 | "session" 16 | ], 17 | "eslintConfig": { 18 | "extends": [ 19 | "plugin:@hapi/module" 20 | ] 21 | }, 22 | "dependencies": { 23 | "@hapi/boom": "^10.0.1", 24 | "@hapi/cryptiles": "^6.0.1", 25 | "@hapi/hoek": "^11.0.2", 26 | "@hapi/validate": "^2.0.1" 27 | }, 28 | "devDependencies": { 29 | "@hapi/code": "^9.0.3", 30 | "@hapi/eslint-plugin": "^6.0.0", 31 | "@hapi/hapi": "^21.2.1", 32 | "@hapi/lab": "^25.1.2", 33 | "@hapi/vision": "^7.0.1", 34 | "handlebars": "^4.7.7" 35 | }, 36 | "scripts": { 37 | "test": "lab -t 100 -a @hapi/code -L", 38 | "test-cov-html": "lab -r html -o coverage.html -a @hapi/code -L" 39 | }, 40 | "license": "BSD-3-Clause" 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @hapi/crumb 4 | 5 | #### CSRF crumb generation and validation for hapi. 6 | 7 | **crumb** is part of the **hapi** ecosystem and was designed to work seamlessly with the [hapi web framework](https://hapi.dev) and its other components (but works great on its own or with other frameworks). If you are using a different web framework and find this module useful, check out [hapi](https://hapi.dev) – they work even better together. 8 | 9 | ### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support 10 | 11 | ## Useful resources 12 | 13 | - [Documentation and API](https://hapi.dev/family/crumb/) 14 | - [Version status](https://hapi.dev/resources/status/#crumb) (builds, dependencies, node versions, licenses, eol) 15 | - [Changelog](https://hapi.dev/family/crumb/changelog/) 16 | - [Project policies](https://hapi.dev/policies/) 17 | - [Free and commercial support options](https://hapi.dev/support/) 18 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Crumb = require('..'); 4 | const Hapi = require('@hapi/hapi'); 5 | const Vision = require('@hapi/vision'); 6 | 7 | 8 | const server = Hapi.server({ 9 | host: '127.0.0.1', 10 | port: 8000 11 | }); 12 | 13 | const plugins = [ 14 | Vision, 15 | { 16 | plugin: Crumb, 17 | options: { 18 | cookieOptions: { 19 | isSecure: false 20 | } 21 | } 22 | } 23 | ]; 24 | 25 | (async () => { 26 | 27 | await server.register(plugins); 28 | 29 | server.views({ 30 | relativeTo: __dirname, 31 | path: 'templates', 32 | engines: { 33 | html: require('handlebars') 34 | } 35 | }); 36 | 37 | server.route({ 38 | method: 'get', 39 | path: '/', 40 | handler: function (request, h) { 41 | 42 | return h.view('index', { title: 'test', message: 'hi' }); 43 | } 44 | }); 45 | 46 | server.route({ 47 | method: 'post', 48 | path: '/', 49 | handler: function (request, h) { 50 | 51 | return h.view('message', { title: 'test', message: request.payload.message }); 52 | } 53 | }); 54 | 55 | await server.start(); 56 | 57 | console.log('Example server running at:', server.info.uri); 58 | })(); 59 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2022, Project contributors 2 | Copyright (c) 2013-2020, Sideway Inc 3 | Copyright (c) 2013-2014, Walmart. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /example/restful.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Crumb = require('..'); 4 | const Hapi = require('@hapi/hapi'); 5 | const Vision = require('@hapi/vision'); 6 | 7 | 8 | const server = Hapi.server({ 9 | host: '127.0.0.1', 10 | port: 8000 11 | }); 12 | 13 | const plugins = [ 14 | Vision, 15 | { 16 | plugin: Crumb, 17 | options: { 18 | restful: true, 19 | cookieOptions: { 20 | isSecure: false 21 | } 22 | } 23 | } 24 | ]; 25 | 26 | // Add Crumb plugin 27 | 28 | (async () => { 29 | 30 | await server.register(plugins); 31 | 32 | server.route([ 33 | 34 | // a "crumb" cookie should be set with any request 35 | // for cross-origin requests, set CORS "credentials" to true 36 | // a route returning the crumb can be created like this 37 | 38 | { 39 | method: 'GET', 40 | path: '/generate', 41 | handler: function (request, h) { 42 | 43 | return { 44 | crumb: server.plugins.crumb.generate(request, h) 45 | }; 46 | } 47 | }, 48 | 49 | // request header "X-CSRF-Token" with crumb value must be set in request for this route 50 | 51 | { 52 | method: 'PUT', 53 | path: '/crumbed', 54 | handler: function (request, h) { 55 | 56 | return 'Crumb route'; 57 | } 58 | } 59 | ]); 60 | 61 | await server.start(); 62 | 63 | console.log('Example restful server running at:', server.info.uri); 64 | })(); 65 | -------------------------------------------------------------------------------- /test/fixtures/cert.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0UqyXDCqWDKpoNQQK/fdr0OkG4gW6DUafxdufH9GmkX/zoKz\ng/SFLrPipzSGINKWtyMvo7mPjXqqVgE10LDI3VFV8IR6fnART+AF8CW5HMBPGt/s\nfQW4W4puvBHkBxWSW1EvbecgNEIS9hTGvHXkFzm4xJ2e9DHp2xoVAjREC73B7JbF\nhc5ZGGchKw+CFmAiNysU0DmBgQcac0eg2pWoT+YGmTeQj6sRXO67n2xy/hA1DuN6\nA4WBK3wM3O4BnTG0dNbWUEbe7yAbV5gEyq57GhJIeYxRvveVDaX90LoAqM4cUH06\n6rciON0UbDHV2LP/JaH5jzBjUyCnKLLo5snlbwIDAQABAoIBAQDJm7YC3pJJUcxb\nc8x8PlHbUkJUjxzZ5MW4Zb71yLkfRYzsxrTcyQA+g+QzA4KtPY8XrZpnkgm51M8e\n+B16AcIMiBxMC6HgCF503i16LyyJiKrrDYfGy2rTK6AOJQHO3TXWJ3eT3BAGpxuS\n12K2Cq6EvQLCy79iJm7Ks+5G6EggMZPfCVdEhffRm2Epl4T7LpIAqWiUDcDfS05n\nNNfAGxxvALPn+D+kzcSF6hpmCVrFVTf9ouhvnr+0DpIIVPwSK/REAF3Ux5SQvFuL\njPmh3bGwfRtcC5d21QNrHdoBVSN2UBLmbHUpBUcOBI8FyivAWJhRfKnhTvXMFG8L\nwaXB51IZAoGBAP/E3uz6zCyN7l2j09wmbyNOi1AKvr1WSmuBJveITouwblnRSdvc\nsYm4YYE0Vb94AG4n7JIfZLKtTN0xvnCo8tYjrdwMJyGfEfMGCQQ9MpOBXAkVVZvP\ne2k4zHNNsfvSc38UNSt7K0HkVuH5BkRBQeskcsyMeu0qK4wQwdtiCoBDAoGBANF7\nFMppYxSW4ir7Jvkh0P8bP/Z7AtaSmkX7iMmUYT+gMFB5EKqFTQjNQgSJxS/uHVDE\nSC5co8WGHnRk7YH2Pp+Ty1fHfXNWyoOOzNEWvg6CFeMHW2o+/qZd4Z5Fep6qCLaa\nFvzWWC2S5YslEaaP8DQ74aAX4o+/TECrxi0z2lllAoGAdRB6qCSyRsI/k4Rkd6Lv\nw00z3lLMsoRIU6QtXaZ5rN335Awyrfr5F3vYxPZbOOOH7uM/GDJeOJmxUJxv+cia\nPQDflpPJZU4VPRJKFjKcb38JzO6C3Gm+po5kpXGuQQA19LgfDeO2DNaiHZOJFrx3\nm1R3Zr/1k491lwokcHETNVkCgYBPLjrZl6Q/8BhlLrG4kbOx+dbfj/euq5NsyHsX\n1uI7bo1Una5TBjfsD8nYdUr3pwWltcui2pl83Ak+7bdo3G8nWnIOJ/WfVzsNJzj7\n/6CvUzR6sBk5u739nJbfgFutBZBtlSkDQPHrqA7j3Ysibl3ZIJlULjMRKrnj6Ans\npCDwkQKBgQCM7gu3p7veYwCZaxqDMz5/GGFUB1My7sK0hcT7/oH61yw3O8pOekee\nuctI1R3NOudn1cs5TAy/aypgLDYTUGQTiBRILeMiZnOrvQQB9cEf7TFgDoRNCcDs\nV/ZWiegVB/WY7H0BkCekuq5bHwjgtJTpvHGqQ9YD7RhE8RSYOhdQ/Q==\n-----END RSA PRIVATE KEY-----\n", 3 | "cert": "-----BEGIN CERTIFICATE-----\nMIIDBjCCAe4CCQDvLNml6smHlTANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJV\nUzETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0\ncyBQdHkgTHRkMB4XDTE0MDEyNTIxMjIxOFoXDTE1MDEyNTIxMjIxOFowRTELMAkG\nA1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0\nIFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nANFKslwwqlgyqaDUECv33a9DpBuIFug1Gn8Xbnx/RppF/86Cs4P0hS6z4qc0hiDS\nlrcjL6O5j416qlYBNdCwyN1RVfCEen5wEU/gBfAluRzATxrf7H0FuFuKbrwR5AcV\nkltRL23nIDRCEvYUxrx15Bc5uMSdnvQx6dsaFQI0RAu9weyWxYXOWRhnISsPghZg\nIjcrFNA5gYEHGnNHoNqVqE/mBpk3kI+rEVzuu59scv4QNQ7jegOFgSt8DNzuAZ0x\ntHTW1lBG3u8gG1eYBMquexoSSHmMUb73lQ2l/dC6AKjOHFB9Ouq3IjjdFGwx1diz\n/yWh+Y8wY1Mgpyiy6ObJ5W8CAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAoSc6Skb4\ng1e0ZqPKXBV2qbx7hlqIyYpubCl1rDiEdVzqYYZEwmst36fJRRrVaFuAM/1DYAmT\nWMhU+yTfA+vCS4tql9b9zUhPw/IDHpBDWyR01spoZFBF/hE1MGNpCSXXsAbmCiVf\naxrIgR2DNketbDxkQx671KwF1+1JOMo9ffXp+OhuRo5NaGIxhTsZ+f/MA4y084Aj\nDI39av50sTRTWWShlN+J7PtdQVA5SZD97oYbeUeL7gI18kAJww9eUdmT0nEjcwKs\nxsQT1fyKbo7AlZBY4KSlUMuGnn0VnAsB9b+LxtXlDfnjyM8bVQx1uAfRo0DO8p/5\n3J5DTjAU55deBQ==\n-----END CERTIFICATE-----\n" 4 | } -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | 2 | ### About 3 | 4 | Crumb is used to diminish CSRF attacks using a random unique token that is validated on the server side. 5 | 6 | Crumb may be used whenever you want to prevent malicious code to execute system commands, that are performed by HTTP requests. For example, if users are able to publish code on your website, malicious code added by a user could force every other user who opens the page, to load and execute code from a third party website e.g. via an HTML image tag. With Crumb implemented into your hapi.js application, you are able to verify requests with unique tokens and prevent the execution of malicious requests. 7 | 8 | ### CORS 9 | 10 | Crumb has been refactored to securely work with CORS, as [OWASP](https://www.owasp.org/index.php/HTML5_Security_Cheat_Sheet#Cross_Origin_Resource_Sharing) recommends using CSRF protection with CORS. 11 | 12 | **It is highly discouraged to have a production servers `cors.origin` setting set to "[\*]" or "true" with Crumb as it will leak the crumb token to potentially malicious sites** 13 | 14 | 15 | ## Usage 16 | 17 | ```js 18 | const Hapi = require('@hapi/hapi'); 19 | const Crumb = require('@hapi/crumb'); 20 | 21 | const server = new Hapi.Server({ port: 8000 }); 22 | 23 | (async () => { 24 | await server.register({ 25 | plugin: Crumb, 26 | 27 | // plugin options 28 | options: {} 29 | }); 30 | 31 | server.route({ 32 | path: '/login', 33 | method: 'GET', 34 | options: { 35 | plugins: { 36 | // route specific options 37 | crumb: {} 38 | }, 39 | handler(request, h) { 40 | // this requires to have a view engine configured 41 | return h.view('some-view'); 42 | } 43 | } 44 | }); 45 | })(); 46 | ``` 47 | 48 | For a complete example see [the examples folder](https://github.com/hapijs/crumb/tree/master/example). 49 | 50 | ## Options 51 | 52 | The following options are available when registering the plugin. 53 | 54 | ### Registration options 55 | 56 | * `key` - the name of the cookie to store the csrf crumb into. Defaults to `crumb`. 57 | * `size` - the length of the crumb to generate. Defaults to `43`, which is 256 bits, see [cryptile](https://hapi.dev/module/cryptiles) for more information. 58 | * `autoGenerate` - whether to automatically generate a new crumb for requests. Defaults to `true`. 59 | * `addToViewContext` - whether to automatically add the crumb to view contexts as the given key. Defaults to `true`. 60 | * `cookieOptions` - storage options for the cookie containing the crumb, see the [server.state()](https://hapi.dev/api#server.state()) documentation of hapi for more information. Default to `cookieOptions.path=/` . **Note that the cookie is not set as secure by default. It should be set as 'secure:true' for production use.** 61 | * `headerName` - specify the name of the custom CSRF header. Defaults to `X-CSRF-Token`. 62 | * `restful` - RESTful mode that validates crumb tokens from *"X-CSRF-Token"* request header for **POST**, **PUT**, **PATCH** and **DELETE** server routes. Disables payload/query crumb validation. Defaults to `false`. 63 | * `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`. 64 | * `enforce` - defaults to true, using enforce with false will set the CSRF header cookie but won't execute the validation 65 | * `logUnauthorized` - whether to add to the request log with tag 'crumb' and data 'validation failed' (defaults to false) 66 | 67 | ### Routes configuration 68 | 69 | 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. 70 | 71 | * `key` - the key used in the view contexts and payloads for the crumb. Defaults to `plugin.key`. 72 | * `source` - can be either `payload` or `query` specifying how the crumb will be sent in requests. Defaults to `payload`. 73 | * `restful` - an override for the server's 'restful' setting. Defaults to `plugin.restful`. 74 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Stream = require('stream'); 4 | 5 | const Boom = require('@hapi/boom'); 6 | const Cryptiles = require('@hapi/cryptiles'); 7 | const Hoek = require('@hapi/hoek'); 8 | const Validate = require('@hapi/validate'); 9 | 10 | 11 | const internals = { 12 | restfulValidatedMethods: ['post', 'put', 'patch', 'delete'] 13 | }; 14 | 15 | 16 | internals.schema = Validate.object().keys({ 17 | key: Validate.string().optional(), 18 | size: Validate.number().optional(), 19 | autoGenerate: Validate.boolean().optional(), 20 | addToViewContext: Validate.boolean().optional(), 21 | cookieOptions: Validate.object().keys(null), 22 | headerName: Validate.string().optional(), 23 | restful: Validate.boolean().optional(), 24 | skip: Validate.func().optional(), 25 | enforce: Validate.boolean().optional(), 26 | logUnauthorized: Validate.boolean().optional() 27 | }); 28 | 29 | 30 | internals.defaults = { 31 | key: 'crumb', 32 | size: 43, // Equal to 256 bits 33 | autoGenerate: true, // If false, must call request.plugins.crumb.generate() manually before usage 34 | addToViewContext: true, // If response is a view, add crumb to context 35 | cookieOptions: { // Cookie options (i.e. hapi server.state) 36 | path: '/' 37 | }, 38 | headerName: 'X-CSRF-Token', // Specify the name of the custom CSRF header 39 | restful: false, // Set to true for custom header crumb validation. Disables payload/query validation 40 | skip: false, // Set to a function which returns true when to skip crumb generation and validation, 41 | enforce: true, // Set to true for setting the CSRF cookie while not performing validation 42 | logUnauthorized: false // Set to true for crumb to write an event to the request log 43 | }; 44 | 45 | 46 | exports.plugin = { 47 | pkg: require('../package.json'), 48 | requirements: { 49 | hapi: '>=20.0.0' 50 | }, 51 | register: function (server, options) { 52 | 53 | Validate.assert(options, internals.schema); 54 | 55 | const settings = Hoek.applyToDefaults(internals.defaults, options); 56 | 57 | const routeDefaults = { 58 | key: settings.key, 59 | restful: settings.restful, 60 | source: 'payload' 61 | }; 62 | 63 | server.state(settings.key, settings.cookieOptions); 64 | 65 | server.ext('onPostAuth', (request, h) => { 66 | 67 | const unauthorizedLogger = () => { 68 | 69 | if (settings.logUnauthorized) { 70 | request.log(['crumb', 'unauthorized'], 'validation failed'); 71 | } 72 | }; 73 | 74 | const getCrumbValue = () => { 75 | 76 | let crumbValue = request.plugins.crumb; 77 | 78 | if (Array.isArray(crumbValue)) { 79 | request.log(['crumb'], 'multiple cookies found'); 80 | crumbValue = request.plugins.crumb[0]; 81 | } 82 | 83 | return crumbValue; 84 | }; 85 | 86 | // If skip function enabled, invoke and if returns true, do not attempt to do anything with crumb 87 | 88 | if (settings.skip && 89 | settings.skip(request, h)) { 90 | 91 | return h.continue; 92 | } 93 | 94 | // Get crumb settings for this route if crumb is enabled on route 95 | 96 | if (request.route.settings.plugins._crumb === undefined) { 97 | if (request.route.settings.plugins.crumb || 98 | !request.route.settings.plugins.hasOwnProperty('crumb')) { 99 | 100 | request.route.settings.plugins._crumb = Hoek.applyToDefaults(routeDefaults, request.route.settings.plugins.crumb ?? {}); 101 | } 102 | else { 103 | request.route.settings.plugins._crumb = false; 104 | } 105 | } 106 | 107 | if (!request.route.settings.cors || 108 | checkCORS(request)) { 109 | 110 | // Read crumb value from cookie if crumb enabled for this route 111 | 112 | if (request.route.settings.plugins._crumb) { 113 | request.plugins.crumb = request.state[settings.key]; 114 | } 115 | 116 | // Generate crumb value if autoGenerate enabled or crumb specifically enabled on route 117 | 118 | if (settings.autoGenerate || 119 | request.route.settings.plugins.crumb) { 120 | 121 | generate(request, h); 122 | } 123 | } 124 | 125 | // Skip validation on dry run 126 | 127 | if (!settings.enforce) { 128 | return h.continue; 129 | } 130 | 131 | // Validate crumb 132 | 133 | const restful = request.route.settings.plugins._crumb ? request.route.settings.plugins._crumb.restful : settings.restful; 134 | if (restful) { 135 | if (!internals.restfulValidatedMethods.includes(request.method) || !request.route.settings.plugins._crumb) { 136 | return h.continue; 137 | } 138 | 139 | const header = request.headers[settings.headerName.toLowerCase()]; 140 | 141 | if (!header) { 142 | unauthorizedLogger(); 143 | throw Boom.forbidden(); 144 | } 145 | 146 | if (header !== getCrumbValue()) { 147 | unauthorizedLogger(); 148 | throw Boom.forbidden(); 149 | } 150 | 151 | return h.continue; 152 | } 153 | 154 | // Not restful 155 | 156 | if (!request.route.settings.plugins._crumb || 157 | request.method !== 'post') { 158 | 159 | return h.continue; 160 | } 161 | 162 | const content = request[request.route.settings.plugins._crumb.source]; 163 | if (!content || 164 | content instanceof Stream) { 165 | 166 | unauthorizedLogger(); 167 | throw Boom.forbidden(); 168 | } 169 | 170 | if (content[request.route.settings.plugins._crumb.key] !== getCrumbValue()) { 171 | unauthorizedLogger(); 172 | throw Boom.forbidden(); 173 | } 174 | 175 | // Remove crumb 176 | 177 | delete request[request.route.settings.plugins._crumb.source][request.route.settings.plugins._crumb.key]; 178 | return h.continue; 179 | }); 180 | 181 | server.ext('onPreResponse', (request, h) => { 182 | 183 | // Add to view context 184 | 185 | const response = request.response; 186 | 187 | if (settings.addToViewContext && 188 | request.plugins.crumb && 189 | request.route.settings.plugins._crumb && 190 | !response.isBoom && 191 | response.variety === 'view') { 192 | 193 | response.source.context = response.source.context ?? {}; 194 | response.source.context[request.route.settings.plugins._crumb.key] = request.plugins.crumb; 195 | } 196 | 197 | return h.continue; 198 | }); 199 | 200 | const checkCORS = function (request) { 201 | 202 | if (request.headers.origin) { 203 | return request.info.cors.isOriginMatch; 204 | } 205 | 206 | return true; 207 | }; 208 | 209 | const generate = function (request, h) { 210 | 211 | if (!request.plugins.crumb) { 212 | const crumb = Cryptiles.randomString(settings.size); 213 | h.state(settings.key, crumb, settings.cookieOptions); 214 | request.plugins.crumb = crumb; 215 | } 216 | 217 | return request.plugins.crumb; 218 | }; 219 | 220 | server.expose({ generate }); 221 | } 222 | }; 223 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Stream = require('stream'); 4 | 5 | const Code = require('@hapi/code'); 6 | const Crumb = require('..'); 7 | const Hapi = require('@hapi/hapi'); 8 | const Lab = require('@hapi/lab'); 9 | const Vision = require('@hapi/vision'); 10 | 11 | const TLSCert = require('./fixtures/cert'); 12 | const Views = require('./fixtures/views'); 13 | 14 | 15 | const internals = {}; 16 | 17 | 18 | const { describe, it } = exports.lab = Lab.script(); 19 | const { expect } = Code; 20 | 21 | internals.viewOptions = { 22 | path: __dirname + '/templates', 23 | engines: { 24 | html: require('handlebars') 25 | } 26 | }; 27 | 28 | 29 | describe('Crumb', () => { 30 | 31 | it('returns view with crumb', async () => { 32 | 33 | const server = Hapi.server(); 34 | 35 | server.route([ 36 | { 37 | method: 'GET', 38 | path: '/1', 39 | handler: (request, h) => { 40 | 41 | expect(request.plugins.crumb).to.exist(); 42 | expect(request.server.plugins.crumb.generate).to.exist(); 43 | 44 | return h.view('index', { 45 | title: 'test', 46 | message: 'hi' 47 | }); 48 | } 49 | }, 50 | { 51 | method: 'POST', 52 | path: '/2', 53 | handler: (request, h) => { 54 | 55 | expect(request.payload).to.equal({ key: 'value' }); 56 | return 'valid'; 57 | } 58 | }, 59 | { 60 | method: 'POST', 61 | path: '/3', 62 | options: { 63 | payload: { 64 | output: 'stream' 65 | } 66 | }, 67 | handler: (request, h) => 'never' 68 | }, 69 | { 70 | method: 'GET', 71 | path: '/4', 72 | options: { 73 | plugins: { 74 | crumb: false 75 | } 76 | }, 77 | handler: (request, h) => { 78 | 79 | return h.view('index', { 80 | title: 'test', 81 | message: 'hi' 82 | }); 83 | } 84 | }, 85 | { 86 | method: 'POST', 87 | path: '/5', 88 | options: { 89 | payload: { 90 | output: 'stream' 91 | } 92 | }, 93 | handler: (request, h) => 'yo' 94 | }, 95 | { 96 | method: 'GET', 97 | path: '/6', 98 | handler: (request, h) => h.view('index') 99 | }, 100 | { 101 | method: 'GET', 102 | path: '/7', 103 | handler: (request, h) => h.redirect('/1') 104 | } 105 | ]); 106 | 107 | await server.register([ 108 | Vision, 109 | { 110 | plugin: Crumb, 111 | options: { 112 | cookieOptions: { 113 | isSecure: true 114 | } 115 | } 116 | } 117 | ]); 118 | 119 | server.views(internals.viewOptions); 120 | 121 | // Works with get requests 122 | const res = await server.inject({ 123 | method: 'GET', 124 | url: '/1' 125 | }); 126 | 127 | expect(res.statusCode).to.equal(200); 128 | 129 | const header = res.headers['set-cookie']; 130 | 131 | expect(header.length).to.equal(1); 132 | expect(header[0]).to.contain('Secure'); 133 | 134 | const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 135 | 136 | expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); 137 | 138 | // Works with crumb on POST body request 139 | const res2 = await server.inject({ 140 | method: 'POST', 141 | url: '/2', 142 | payload: '{ "key": "value", "crumb": "' + cookie[1] + '" }', 143 | headers: { 144 | cookie: 'crumb=' + cookie[1] 145 | } 146 | }); 147 | 148 | expect(res2.result).to.equal('valid'); 149 | 150 | // Rejects on invalid crumb on POST body request 151 | const res3 = await server.inject({ 152 | method: 'POST', 153 | url: '/2', 154 | payload: '{ "key": "value", "crumb": "x' + cookie[1] + '" }', 155 | headers: { 156 | cookie: 'crumb=' + cookie[1] 157 | } 158 | }); 159 | 160 | expect(res3.statusCode).to.equal(403); 161 | 162 | // Rejects on missing crumb on POST stream body requests 163 | const res4 = await server.inject({ 164 | method: 'POST', 165 | url: '/3', 166 | headers: { 167 | cookie: 'crumb=' + cookie[1] 168 | } 169 | }); 170 | 171 | expect(res4.statusCode).to.equal(403); 172 | 173 | // Works with crumb generation disabled 174 | const res5 = await server.inject({ 175 | method: 'GET', 176 | url: '/4' 177 | }); 178 | 179 | expect(res5.result).to.equal(Views.viewWithoutCrumb()); 180 | 181 | // Works on POST stream requests 182 | 183 | 184 | 185 | const TestStream = class extends Stream.Readable { 186 | 187 | constructor(options) { 188 | 189 | super(options); 190 | this._max = 2; 191 | this._index = 1; 192 | } 193 | 194 | _read() { 195 | 196 | const i = this._index++; 197 | if (i > this._max) { 198 | this.push(null); 199 | } 200 | else { 201 | const str = '' + i; 202 | const buf = Buffer.from(str, 'ascii'); 203 | this.push(buf); 204 | } 205 | } 206 | }; 207 | 208 | const res6 = await server.inject({ 209 | method: 'POST', 210 | url: '/5', 211 | payload: new TestStream(), 212 | headers: { 213 | 'content-type': 'application/octet-stream', 214 | 'content-disposition': 'attachment; filename="test.txt"' 215 | }, 216 | simulate: { 217 | end: true 218 | } 219 | }); 220 | 221 | expect(res6.statusCode).to.equal(403); 222 | 223 | // Works with get requests with no context 224 | const res7 = await server.inject({ 225 | method: 'GET', 226 | url: '/6' 227 | }); 228 | 229 | const header2 = res7.headers['set-cookie']; 230 | expect(header2.length).to.equal(1); 231 | expect(header2[0]).to.contain('Secure'); 232 | 233 | const cookie2 = header2[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 234 | expect(res7.result).to.equal(Views.viewWithCrumbAndNoContext(cookie2[1])); 235 | 236 | // Works with redirections 237 | const res8 = await server.inject({ 238 | method: 'GET', 239 | url: '/7' 240 | }); 241 | 242 | const cookie3 = res8.headers['set-cookie'].toString(); 243 | expect(cookie3).to.contain('crumb'); 244 | 245 | const headers = {}; 246 | headers.origin = 'http://127.0.0.1'; 247 | 248 | // Works with cross-origin enabled requests 249 | const res9 = await server.inject({ 250 | method: 'GET', 251 | url: '/1', 252 | headers 253 | }); 254 | 255 | const cookie4 = res9.headers['set-cookie'].toString(); 256 | expect(cookie4).to.contain('crumb'); 257 | }); 258 | 259 | it('Does not add crumb to view context when "addToViewContext" option set to false', async () => { 260 | 261 | const server = Hapi.server(); 262 | 263 | server.route({ 264 | method: 'GET', 265 | path: '/1', 266 | handler: (request, h) => { 267 | 268 | expect(request.plugins.crumb).to.exist(); 269 | expect(request.server.plugins.crumb.generate).to.exist(); 270 | 271 | return h.view('index', { 272 | title: 'test', 273 | message: 'hi' 274 | }); 275 | } 276 | }); 277 | 278 | const plugins = [ 279 | Vision, 280 | { 281 | plugin: Crumb, 282 | options: { 283 | cookieOptions: { 284 | isSecure: true 285 | }, 286 | addToViewContext: false 287 | } 288 | } 289 | ]; 290 | 291 | await server.register(plugins); 292 | server.views(internals.viewOptions); 293 | 294 | const res = await server.inject({ 295 | method: 'GET', 296 | url: '/1' 297 | }); 298 | 299 | expect(res.result).to.equal(Views.viewWithoutCrumb()); 300 | }); 301 | 302 | it('Works without specifying plugin options', async () => { 303 | 304 | const server = Hapi.server(); 305 | 306 | server.route({ 307 | method: 'GET', 308 | path: '/1', 309 | handler: (request, h) => { 310 | 311 | expect(request.plugins.crumb).to.exist(); 312 | expect(request.server.plugins.crumb.generate).to.exist(); 313 | 314 | return h.view('index', { 315 | title: 'test', 316 | message: 'hi' 317 | }); 318 | } 319 | }); 320 | 321 | await server.register([Vision, Crumb]); 322 | 323 | server.views(internals.viewOptions); 324 | 325 | const res = await server.inject({ 326 | method: 'GET', 327 | url: '/1' 328 | }); 329 | 330 | const header = res.headers['set-cookie']; 331 | expect(header.length).to.equal(1); 332 | 333 | const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 334 | expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); 335 | }); 336 | 337 | it('Adds to the request log if plugin option logUnauthorized is set to true', async () => { 338 | 339 | const server = Hapi.server(); 340 | 341 | let logFound; 342 | const preResponse = function (request, h) { 343 | 344 | const logs = request.logs; 345 | logFound = logs.find((log) => { 346 | 347 | return log.tags[0] === 'crumb' && log.data === 'validation failed'; 348 | }); 349 | 350 | return h.continue; 351 | }; 352 | 353 | server.ext('onPreResponse', preResponse); 354 | 355 | server.route({ 356 | method: 'POST', 357 | path: '/1', 358 | config: { 359 | log: { 360 | collect: true 361 | } 362 | }, 363 | handler: (request, h) => 'test' 364 | }); 365 | 366 | await server.register([ 367 | { 368 | plugin: Crumb, 369 | options: { 370 | logUnauthorized: true 371 | } 372 | } 373 | ]); 374 | 375 | const headers = {}; 376 | headers['X-API-Token'] = 'test'; 377 | 378 | await server.inject({ 379 | method: 'POST', 380 | url: '/1', 381 | payload: '{ "key": true }', 382 | headers 383 | }); 384 | expect(logFound).to.exist(); 385 | }); 386 | 387 | it('Does not add to the request log if plugin option logUnauthorized is set to false', async () => { 388 | 389 | const server = Hapi.server(); 390 | 391 | let logFound; 392 | const preResponse = function (request, h) { 393 | 394 | const logs = request.logs; 395 | logFound = logs.find((log) => { 396 | 397 | return log.tags[0] === 'crumb' && log.data === 'validation failed'; 398 | }); 399 | 400 | return h.continue; 401 | }; 402 | 403 | server.ext('onPreResponse', preResponse); 404 | 405 | server.route({ 406 | method: 'POST', 407 | path: '/1', 408 | config: { 409 | log: { 410 | collect: true 411 | } 412 | }, 413 | handler: (request, h) => 'test' 414 | }); 415 | 416 | await server.register([ 417 | { 418 | plugin: Crumb, 419 | options: { 420 | logUnauthorized: false 421 | } 422 | } 423 | ]); 424 | 425 | const headers = {}; 426 | headers['X-API-Token'] = 'test'; 427 | 428 | await server.inject({ 429 | method: 'POST', 430 | url: '/1', 431 | headers 432 | }); 433 | 434 | expect(logFound).to.not.exist(); 435 | }); 436 | 437 | it('should fail to register with bad options', async () => { 438 | 439 | const server = Hapi.server(); 440 | 441 | try { 442 | await server.register({ 443 | plugin: Crumb, 444 | options: { 445 | foo: 'bar' 446 | } 447 | }); 448 | } 449 | catch (err) { 450 | expect(err).to.exist(); 451 | expect(err.name).to.equal('ValidationError'); 452 | // TODO: Message validation fails because of formatting produced by assert :( 453 | // expect(err.message).to.equal('"foo" is not allowed'); 454 | } 455 | }); 456 | 457 | it('route uses crumb when route.options.plugins.crumb set to true and autoGenerate set to false', async () => { 458 | 459 | const server = Hapi.server(); 460 | 461 | server.route([ 462 | { 463 | method: 'GET', 464 | path: '/1', 465 | handler: (request, h) => { 466 | 467 | const crumb = request.plugins.crumb; 468 | 469 | expect(crumb).to.not.exist(); 470 | 471 | return 'bonjour'; 472 | } 473 | }, 474 | { 475 | method: 'GET', 476 | path: '/2', 477 | options: { 478 | plugins: { 479 | crumb: true 480 | } 481 | }, 482 | handler: (request, h) => 'hola' 483 | } 484 | ]); 485 | 486 | await server.register([ 487 | Vision, 488 | { 489 | plugin: Crumb, 490 | options: { 491 | autoGenerate: false 492 | } 493 | } 494 | ]); 495 | 496 | server.views(internals.viewOptions); 497 | 498 | await server.inject({ 499 | method: 'GET', 500 | url: '/1' 501 | }); 502 | 503 | const res = await server.inject({ method: 'GET', url: '/2' }); 504 | 505 | const header = res.headers['set-cookie']; 506 | expect(header.length).to.equal(1); 507 | }); 508 | 509 | it('route should still validate crumb when autoGenerate is false and route.options.plugins.crumb is not defined', async () => { 510 | 511 | const server = Hapi.server(); 512 | 513 | server.route([ 514 | { 515 | method: 'POST', 516 | path: '/1', 517 | handler: (request, h) => { 518 | 519 | return 'bonjour'; 520 | } 521 | } 522 | ]); 523 | 524 | await server.register([ 525 | Vision, 526 | { 527 | plugin: Crumb, 528 | options: { 529 | autoGenerate: false, 530 | restful: true 531 | } 532 | } 533 | ]); 534 | 535 | server.views(internals.viewOptions); 536 | 537 | let res = await server.inject({ method: 'POST', url: '/1' }); 538 | expect(res.statusCode).to.equal(403); 539 | 540 | const crumbValue = 'someCrumbValue'; 541 | res = await server.inject({ 542 | method: 'POST', 543 | url: '/1', 544 | headers: { 545 | cookie: `crumb=${crumbValue}`, 546 | 'X-CSRF-token': crumbValue 547 | } 548 | }); 549 | expect(res.statusCode).to.equal(200); 550 | }); 551 | 552 | it('fails validation when no payload provided and not using restful mode', async () => { 553 | 554 | const server = Hapi.server(); 555 | 556 | server.route({ 557 | method: 'POST', 558 | path: '/1', 559 | handler: (request, h) => 'test' 560 | }); 561 | 562 | await server.register([Crumb]); 563 | 564 | const headers = {}; 565 | headers['X-API-Token'] = 'test'; 566 | 567 | const res = await server.inject({ 568 | method: 'POST', 569 | url: '/1', 570 | headers 571 | }); 572 | 573 | expect(res.statusCode).to.equal(403); 574 | }); 575 | 576 | it('does not validate crumb when "skip" option returns true', async () => { 577 | 578 | const server = Hapi.server(); 579 | 580 | server.route({ 581 | method: 'POST', 582 | path: '/1', 583 | handler: (request, h) => 'test' 584 | }); 585 | 586 | const skip = (request) => request.headers['x-api-token'] === 'test'; 587 | 588 | const plugins = [ 589 | { 590 | plugin: Crumb, 591 | options: { 592 | skip 593 | } 594 | } 595 | ]; 596 | 597 | await server.register(plugins); 598 | 599 | const headers = { 600 | 'X-API-Token': 'test' 601 | }; 602 | 603 | const res = await server.inject({ 604 | method: 'POST', 605 | url: '/1', 606 | headers 607 | }); 608 | 609 | const header = res.headers['set-cookie']; 610 | 611 | expect(res.statusCode).to.equal(200); 612 | expect(header).to.not.exist(); 613 | }); 614 | 615 | it('validates crumb when "skip" option returns false', async () => { 616 | 617 | const server = Hapi.server(); 618 | 619 | server.route({ 620 | method: 'POST', 621 | path: '/1', 622 | handler: (request, h) => 'test' 623 | }); 624 | 625 | const skip = (request) => false; 626 | 627 | const plugins = [ 628 | { 629 | plugin: Crumb, 630 | options: { 631 | skip 632 | } 633 | } 634 | ]; 635 | 636 | await server.register(plugins); 637 | 638 | const headers = { 639 | 'X-API-Token': 'test' 640 | }; 641 | 642 | const res = await server.inject({ 643 | method: 'POST', 644 | url: '/1', 645 | headers 646 | }); 647 | 648 | expect(res.statusCode).to.equal(403); 649 | }); 650 | 651 | it('ensures crumb "skip" option is a function', async () => { 652 | 653 | const server = Hapi.server(); 654 | 655 | server.route({ 656 | method: 'POST', 657 | path: '/1', 658 | handler: (request, h) => 'test' 659 | }); 660 | 661 | const skip = true; 662 | 663 | try { 664 | await server.register([ 665 | Vision, 666 | { 667 | plugin: Crumb, 668 | options: { skip } 669 | } 670 | ]); 671 | } 672 | catch (err) { 673 | expect(err).to.exist(); 674 | } 675 | }); 676 | 677 | it('does not set crumb cookie insecurely', async () => { 678 | 679 | const server = Hapi.server({ 680 | host: 'localhost', 681 | port: 80, 682 | routes: { 683 | cors: true 684 | } 685 | }); 686 | 687 | server.route({ 688 | method: 'GET', 689 | path: '/1', 690 | options: { 691 | cors: false 692 | }, 693 | handler: (request, h) => 'test' 694 | }); 695 | 696 | server.route({ 697 | method: 'GET', 698 | path: '/2', 699 | handler: (request, h) => 'test' 700 | }); 701 | 702 | server.route({ 703 | method: 'GET', 704 | path: '/3', 705 | options: { 706 | cors: { 707 | origin: ['http://127.0.0.1'] 708 | } 709 | }, 710 | handler: (request, h) => 'test' 711 | }); 712 | 713 | await server.register(Crumb); 714 | 715 | const headers = {}; 716 | 717 | const res = await server.inject({ 718 | method: 'GET', 719 | url: '/1', 720 | headers 721 | }); 722 | 723 | const header = res.headers['set-cookie']; 724 | expect(header[0]).to.contain('crumb'); 725 | 726 | headers.origin = 'http://localhost'; 727 | 728 | const res2 = await server.inject({ 729 | method: 'GET', 730 | url: '/2', 731 | headers 732 | }); 733 | 734 | const header2 = res2.headers['set-cookie']; 735 | expect(header2[0]).to.contain('crumb'); 736 | 737 | headers.origin = 'http://127.0.0.1'; 738 | 739 | const res3 = await server.inject({ 740 | method: 'GET', 741 | url: '/3', 742 | headers 743 | }); 744 | 745 | const header3 = res3.headers['set-cookie']; 746 | 747 | expect(header3[0]).to.contain('crumb'); 748 | 749 | const res4 = await server.inject({ 750 | method: 'GET', 751 | url: '/3' 752 | }); 753 | 754 | const header4 = res4.headers['set-cookie']; 755 | 756 | expect(header4[0]).to.contain('crumb'); 757 | 758 | headers.origin = 'http://badsite.com'; 759 | 760 | const res5 = await server.inject({ 761 | method: 'GET', 762 | url: '/3', 763 | headers 764 | }); 765 | 766 | const header5 = res5.headers['set-cookie']; 767 | expect(header5).to.not.exist(); 768 | }); 769 | 770 | it('does not set crumb cookie insecurely using https', async () => { 771 | 772 | const options = { 773 | host: 'localhost', 774 | port: 443, 775 | tls: TLSCert 776 | }; 777 | 778 | const server = Hapi.server(options); 779 | 780 | server.route([ 781 | { 782 | method: 'GET', 783 | path: '/1', 784 | handler: (request, h) => 'test' 785 | } 786 | ]); 787 | 788 | await server.register(Crumb); 789 | 790 | const res = await server.inject({ 791 | method: 'GET', 792 | url: '/1', 793 | headers: { 794 | host: 'localhost:443' 795 | } 796 | }); 797 | 798 | const header = res.headers['set-cookie']; 799 | expect(header[0]).to.contain('crumb'); 800 | }); 801 | 802 | it('validates crumb with X-CSRF-Token header', async () => { 803 | 804 | const server = Hapi.server(); 805 | 806 | server.route([ 807 | { 808 | method: 'GET', 809 | path: '/1', 810 | handler: (request, h) => { 811 | 812 | expect(request.plugins.crumb).to.exist(); 813 | expect(request.server.plugins.crumb.generate).to.exist(); 814 | 815 | return h.view('index', { 816 | title: 'test', 817 | message: 'hi' 818 | }); 819 | } 820 | }, 821 | { 822 | method: 'POST', 823 | path: '/2', 824 | handler: (request, h) => { 825 | 826 | expect(request.payload).to.equal({ key: 'value' }); 827 | return 'valid'; 828 | } 829 | }, 830 | { 831 | method: 'POST', 832 | path: '/3', 833 | options: { payload: { output: 'stream' } }, 834 | handler: (request, h) => 'never' 835 | }, 836 | { 837 | method: 'PUT', 838 | path: '/4', 839 | handler: (request, h) => { 840 | 841 | expect(request.payload).to.equal({ key: 'value' }); 842 | return 'valid'; 843 | } 844 | }, 845 | { 846 | method: 'PATCH', 847 | path: '/5', 848 | handler: (request, h) => { 849 | 850 | expect(request.payload).to.equal({ key: 'value' }); 851 | return 'valid'; 852 | } 853 | }, 854 | { 855 | method: 'DELETE', 856 | path: '/6', 857 | handler: (request, h) => 'valid' 858 | }, 859 | { 860 | method: 'POST', 861 | path: '/7', 862 | options: { 863 | plugins: { 864 | crumb: false 865 | } 866 | }, 867 | handler: (request, h) => { 868 | 869 | expect(request.payload).to.equal({ key: 'value' }); 870 | return 'valid'; 871 | } 872 | }, 873 | { 874 | method: 'POST', 875 | path: '/8', 876 | options: { 877 | plugins: { 878 | crumb: { 879 | restful: false, 880 | source: 'payload' 881 | } 882 | } 883 | }, 884 | handler: (request, h) => { 885 | 886 | expect(request.payload).to.equal({ key: 'value' }); 887 | return 'valid'; 888 | } 889 | } 890 | 891 | ]); 892 | 893 | await server.register([ 894 | Vision, 895 | { 896 | plugin: Crumb, 897 | options: { 898 | restful: true, 899 | cookieOptions: { 900 | isSecure: true 901 | } 902 | } 903 | } 904 | ]); 905 | 906 | server.views(internals.viewOptions); 907 | 908 | const res = await server.inject({ 909 | method: 'GET', 910 | url: '/1' 911 | }); 912 | 913 | const header = res.headers['set-cookie']; 914 | expect(header.length).to.equal(1); 915 | expect(header[0]).to.contain('Secure'); 916 | 917 | const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 918 | 919 | const validHeader = { 920 | cookie: 'crumb=' + cookie[1], 921 | 'x-csrf-token': cookie[1] 922 | }; 923 | 924 | const invalidHeader = { 925 | cookie: 'crumb=' + cookie[1], 926 | 'x-csrf-token': 'x' + cookie[1] 927 | }; 928 | 929 | expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); 930 | 931 | const res2 = await server.inject({ 932 | method: 'POST', 933 | url: '/2', 934 | payload: '{ "key": "value" }', 935 | headers: validHeader 936 | }); 937 | 938 | expect(res2.result).to.equal('valid'); 939 | 940 | const res3 = await server.inject({ 941 | method: 'POST', 942 | url: '/2', 943 | payload: '{ "key": "value" }', 944 | headers: invalidHeader 945 | }); 946 | 947 | expect(res3.statusCode).to.equal(403); 948 | 949 | const res4 = await server.inject({ 950 | method: 'POST', 951 | url: '/3', 952 | headers: { 953 | cookie: 'crumb=' + cookie[1] 954 | } 955 | }); 956 | 957 | expect(res4.statusCode).to.equal(403); 958 | 959 | const res5 = await server.inject({ 960 | method: 'PUT', 961 | url: '/4', 962 | payload: '{ "key": "value" }', 963 | headers: validHeader 964 | }); 965 | 966 | expect(res5.result).to.equal('valid'); 967 | 968 | const res6 = await server.inject({ 969 | method: 'PUT', 970 | url: '/4', 971 | payload: '{ "key": "value" }', 972 | headers: invalidHeader 973 | }); 974 | 975 | expect(res6.statusCode).to.equal(403); 976 | 977 | const res7 = await server.inject({ 978 | method: 'PATCH', 979 | url: '/5', 980 | payload: '{ "key": "value" }', 981 | headers: validHeader 982 | }); 983 | 984 | expect(res7.result).to.equal('valid'); 985 | 986 | const res8 = await server.inject({ 987 | method: 'PATCH', 988 | url: '/5', 989 | payload: '{ "key": "value" }', 990 | headers: invalidHeader 991 | }); 992 | 993 | expect(res8.statusCode).to.equal(403); 994 | 995 | const res9 = await server.inject({ 996 | method: 'DELETE', 997 | url: '/6', 998 | headers: validHeader 999 | }); 1000 | 1001 | expect(res9.result).to.equal('valid'); 1002 | 1003 | const res10 = await server.inject({ 1004 | method: 'DELETE', 1005 | url: '/6', 1006 | headers: invalidHeader 1007 | }); 1008 | 1009 | expect(res10.statusCode).to.equal(403); 1010 | 1011 | const res11 = await server.inject({ 1012 | method: 'POST', 1013 | url: '/7', 1014 | payload: '{ "key": "value" }' 1015 | }); 1016 | 1017 | expect(res11.result).to.equal('valid'); 1018 | 1019 | const payload = { key: 'value', crumb: cookie[1] }; 1020 | 1021 | delete validHeader['x-csrf-token']; 1022 | 1023 | const res12 = await server.inject({ 1024 | method: 'POST', 1025 | url: '/8', 1026 | payload: JSON.stringify(payload), 1027 | headers: validHeader 1028 | }); 1029 | 1030 | expect(res12.statusCode).to.equal(200); 1031 | }); 1032 | 1033 | it('validates crumb with a custom header name', async () => { 1034 | 1035 | const server = Hapi.server(); 1036 | 1037 | server.route([ 1038 | { 1039 | method: 'GET', 1040 | path: '/1', 1041 | handler: (request, h) => { 1042 | 1043 | expect(request.plugins.crumb).to.exist(); 1044 | expect(request.server.plugins.crumb.generate).to.exist(); 1045 | 1046 | return h.view('index', { 1047 | title: 'test', 1048 | message: 'hi' 1049 | }); 1050 | } 1051 | }, 1052 | { 1053 | method: 'POST', 1054 | path: '/2', 1055 | handler: (request, h) => { 1056 | 1057 | expect(request.payload).to.equal({ key: 'value' }); 1058 | return 'valid'; 1059 | } 1060 | }, 1061 | { 1062 | method: 'POST', 1063 | path: '/3', 1064 | options: { payload: { output: 'stream' } }, 1065 | handler: (request, h) => 'never' 1066 | }, 1067 | { 1068 | method: 'PUT', 1069 | path: '/4', 1070 | handler: (request, h) => { 1071 | 1072 | expect(request.payload).to.equal({ key: 'value' }); 1073 | return 'valid'; 1074 | } 1075 | }, 1076 | { 1077 | method: 'PATCH', 1078 | path: '/5', 1079 | handler: (request, h) => { 1080 | 1081 | expect(request.payload).to.equal({ key: 'value' }); 1082 | return 'valid'; 1083 | } 1084 | }, 1085 | { 1086 | method: 'DELETE', 1087 | path: '/6', 1088 | handler: (request, h) => 'valid' 1089 | }, 1090 | { 1091 | method: 'POST', 1092 | path: '/7', 1093 | options: { 1094 | plugins: { 1095 | crumb: false 1096 | } 1097 | }, 1098 | handler: (request, h) => { 1099 | 1100 | expect(request.payload).to.equal({ key: 'value' }); 1101 | return 'valid'; 1102 | } 1103 | }, 1104 | { 1105 | method: 'POST', 1106 | path: '/8', 1107 | options: { 1108 | plugins: { 1109 | crumb: { 1110 | restful: false, 1111 | source: 'payload' 1112 | } 1113 | } 1114 | }, 1115 | handler: (request, h) => { 1116 | 1117 | expect(request.payload).to.equal({ key: 'value' }); 1118 | return 'valid'; 1119 | } 1120 | } 1121 | 1122 | ]); 1123 | 1124 | await server.register([ 1125 | Vision, 1126 | { 1127 | plugin: Crumb, 1128 | options: { 1129 | restful: true, 1130 | cookieOptions: { 1131 | isSecure: true 1132 | }, 1133 | headerName: 'X-CUSTOM-TOKEN' 1134 | } 1135 | } 1136 | ]); 1137 | 1138 | server.views(internals.viewOptions); 1139 | 1140 | const res = await server.inject({ 1141 | method: 'GET', 1142 | url: '/1' 1143 | }); 1144 | 1145 | const header = res.headers['set-cookie']; 1146 | expect(header.length).to.equal(1); 1147 | expect(header[0]).to.contain('Secure'); 1148 | 1149 | const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 1150 | 1151 | const validHeader = { 1152 | cookie: 'crumb=' + cookie[1], 1153 | 'x-custom-token': cookie[1] 1154 | }; 1155 | 1156 | const invalidHeader = { 1157 | cookie: 'crumb=' + cookie[1], 1158 | 'x-custom-token': 'x' + cookie[1] 1159 | }; 1160 | 1161 | expect(res.result).to.equal(Views.viewWithCrumb(cookie[1])); 1162 | 1163 | const res2 = await server.inject({ 1164 | method: 'POST', 1165 | url: '/2', 1166 | payload: '{ "key": "value" }', 1167 | headers: validHeader 1168 | }); 1169 | 1170 | expect(res2.result).to.equal('valid'); 1171 | 1172 | const res3 = await server.inject({ 1173 | method: 'POST', 1174 | url: '/2', 1175 | payload: '{ "key": "value" }', 1176 | headers: invalidHeader 1177 | }); 1178 | 1179 | expect(res3.statusCode).to.equal(403); 1180 | 1181 | const res4 = await server.inject({ 1182 | method: 'POST', 1183 | url: '/3', 1184 | headers: { 1185 | cookie: 'crumb=' + cookie[1] 1186 | } 1187 | }); 1188 | 1189 | expect(res4.statusCode).to.equal(403); 1190 | 1191 | const res5 = await server.inject({ 1192 | method: 'PUT', 1193 | url: '/4', 1194 | payload: '{ "key": "value" }', 1195 | headers: validHeader 1196 | }); 1197 | 1198 | expect(res5.result).to.equal('valid'); 1199 | 1200 | const res6 = await server.inject({ 1201 | method: 'PUT', 1202 | url: '/4', 1203 | payload: '{ "key": "value" }', 1204 | headers: invalidHeader 1205 | }); 1206 | 1207 | expect(res6.statusCode).to.equal(403); 1208 | 1209 | const res7 = await server.inject({ 1210 | method: 'PATCH', 1211 | url: '/5', 1212 | payload: '{ "key": "value" }', 1213 | headers: validHeader 1214 | }); 1215 | 1216 | expect(res7.result).to.equal('valid'); 1217 | 1218 | const res8 = await server.inject({ 1219 | method: 'PATCH', 1220 | url: '/5', 1221 | payload: '{ "key": "value" }', 1222 | headers: invalidHeader 1223 | }); 1224 | 1225 | expect(res8.statusCode).to.equal(403); 1226 | 1227 | const res9 = await server.inject({ 1228 | method: 'DELETE', 1229 | url: '/6', 1230 | headers: validHeader 1231 | }); 1232 | 1233 | expect(res9.result).to.equal('valid'); 1234 | 1235 | const res10 = await server.inject({ 1236 | method: 'DELETE', 1237 | url: '/6', 1238 | headers: invalidHeader 1239 | }); 1240 | 1241 | expect(res10.statusCode).to.equal(403); 1242 | 1243 | const res11 = await server.inject({ 1244 | method: 'POST', 1245 | url: '/7', 1246 | payload: '{ "key": "value" }' 1247 | }); 1248 | 1249 | expect(res11.result).to.equal('valid'); 1250 | 1251 | const payload = { key: 'value', crumb: cookie[1] }; 1252 | 1253 | delete validHeader['x-custom-token']; 1254 | 1255 | const res12 = await server.inject({ 1256 | method: 'POST', 1257 | url: '/8', 1258 | payload: JSON.stringify(payload), 1259 | headers: validHeader 1260 | }); 1261 | 1262 | expect(res12.statusCode).to.equal(200); 1263 | }); 1264 | 1265 | it('Adds to the request log if there are multiple cookie values', async () => { 1266 | 1267 | const server = Hapi.server(); 1268 | let logFound; 1269 | 1270 | const preResponse = function (request, h) { 1271 | 1272 | const logs = request.logs; 1273 | logFound = logs.find((log) => { 1274 | 1275 | return log.tags[0] === 'crumb' && log.data === 'multiple cookies found'; 1276 | }); 1277 | 1278 | return h.continue; 1279 | }; 1280 | 1281 | server.ext('onPreResponse', preResponse); 1282 | 1283 | server.route({ 1284 | method: 'GET', 1285 | path: '/1', 1286 | handler: (request, h) => { 1287 | 1288 | return h.view('index', { 1289 | title: 'test', 1290 | message: 'hi' 1291 | }); 1292 | } 1293 | }); 1294 | 1295 | server.route({ 1296 | method: 'POST', 1297 | path: '/2', 1298 | config: { 1299 | log: { 1300 | collect: true 1301 | } 1302 | }, 1303 | handler: (request, h) => { 1304 | 1305 | return 'success'; 1306 | } 1307 | }); 1308 | 1309 | await server.register([{ 1310 | plugin: Vision 1311 | }, { 1312 | plugin: Crumb 1313 | }]); 1314 | 1315 | server.views(internals.viewOptions); 1316 | 1317 | const res = await server.inject({ 1318 | method: 'GET', 1319 | url: '/1' 1320 | }); 1321 | 1322 | const header = res.headers['set-cookie']; 1323 | 1324 | const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 1325 | 1326 | const headers = { 1327 | cookie: 'crumb=' + cookie[1] + '; crumb=' + cookie[1] // multiple cookies 1328 | }; 1329 | 1330 | await server.inject({ 1331 | method: 'POST', 1332 | url: '/2', 1333 | payload: '{ "key": "value", "crumb": "' + cookie[1] + '" }', 1334 | headers 1335 | }); 1336 | expect(logFound).to.exist(); 1337 | }); 1338 | 1339 | it('Adds to the request log if there are multiple cookie values in restful mode', async () => { 1340 | 1341 | const server = Hapi.server(); 1342 | let logFound; 1343 | 1344 | const preResponse = function (request, h) { 1345 | 1346 | const logs = request.logs; 1347 | logFound = logs.find((log) => { 1348 | 1349 | return log.tags[0] === 'crumb' && log.data === 'multiple cookies found'; 1350 | }); 1351 | 1352 | return h.continue; 1353 | }; 1354 | 1355 | server.ext('onPreResponse', preResponse); 1356 | 1357 | server.route({ 1358 | method: 'GET', 1359 | path: '/1', 1360 | handler: (request, h) => { 1361 | 1362 | return h.view('index', { 1363 | title: 'test', 1364 | message: 'hi' 1365 | }); 1366 | } 1367 | }); 1368 | 1369 | server.route({ 1370 | method: 'POST', 1371 | path: '/2', 1372 | config: { 1373 | log: { 1374 | collect: true 1375 | } 1376 | }, 1377 | handler: (request, h) => { 1378 | 1379 | return 'success'; 1380 | } 1381 | }); 1382 | 1383 | await server.register([{ 1384 | plugin: Vision 1385 | }, { 1386 | plugin: Crumb, 1387 | options: { 1388 | restful: true 1389 | } 1390 | }]); 1391 | 1392 | server.views(internals.viewOptions); 1393 | 1394 | const res = await server.inject({ 1395 | method: 'GET', 1396 | url: '/1' 1397 | }); 1398 | 1399 | const header = res.headers['set-cookie']; 1400 | 1401 | const cookie = header[0].match(/crumb=([^\x00-\x20\"\,\;\\\x7F]*)/); 1402 | 1403 | const headers = { 1404 | cookie: 'crumb=' + cookie[1] + '; crumb=' + cookie[1], // multiple cookies 1405 | 'X-CSRF-Token': cookie[1] 1406 | }; 1407 | 1408 | await server.inject({ 1409 | method: 'POST', 1410 | url: '/2', 1411 | payload: '{ "stuff": true }', 1412 | headers 1413 | }); 1414 | expect(logFound).to.exist(); 1415 | }); 1416 | 1417 | it('should set cookie but ignore check with enforce flag turned off', async () => { 1418 | 1419 | const server = Hapi.server(); 1420 | 1421 | server.route({ 1422 | method: 'POST', 1423 | path: '/1', 1424 | handler: (request, h) => 'test' 1425 | }); 1426 | 1427 | const plugins = [ 1428 | { 1429 | plugin: Crumb, 1430 | options: { 1431 | enforce: false 1432 | } 1433 | } 1434 | ]; 1435 | 1436 | await server.register(plugins); 1437 | 1438 | const headers = { 1439 | 'X-API-Token': 'test' 1440 | }; 1441 | 1442 | const res = await server.inject({ 1443 | method: 'POST', 1444 | url: '/1', 1445 | headers 1446 | }); 1447 | 1448 | const header = res.headers['set-cookie']; 1449 | 1450 | expect(header).to.exist(); 1451 | expect(res.statusCode).to.equal(200); 1452 | }); 1453 | }); 1454 | --------------------------------------------------------------------------------