├── .gitignore ├── .travis.yml ├── .eslintrc ├── package.json ├── LICENSE ├── README.md ├── lib └── index.js └── test └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage.html 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 4.0 5 | - 4 6 | - 5 7 | 8 | sudo: false -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "strict": [2, "global"], 6 | "ecmaFeatures": { 7 | "arrowFunctions": 2, 8 | "blockBindings": 2 9 | }, 10 | "rules": { 11 | "no-var": 2, 12 | "semi": 2, 13 | "arrow-body-style": 2, 14 | "no-shadow": [1, { "allow": ["err", "res"]}] 15 | }, 16 | "extends": "hapi" 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-brute", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/salzhrani/hapi-brute", 5 | "description": "Hapi bruteforce prevention", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "lint": "eslint lib/**", 9 | "test": "lab -r console -t 90 -a code -L", 10 | "test-cov-html": "lab -r html -o coverage.html -a code -L" 11 | }, 12 | "author": "Samy Alzhrani", 13 | "license": "ISC", 14 | "dependencies": { 15 | "boom": "^3.0.0", 16 | "hapi": ">=11.x.x", 17 | "joi": "^7.0.0" 18 | }, 19 | "devDependencies": { 20 | "code": "^2.0.0", 21 | "eslint": "^1.9.0", 22 | "eslint-config-hapi": "^6.1.1", 23 | "lab": "^7.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Samy Alzhrani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/salzhrani/hapi-brute.svg)](https://travis-ci.org/salzhrani/hapi-brute) 2 | 3 | **Brute** adds brute force mitigation to [**hapi**](https://github.com/hapijs/hapi)-based application servers. It creates a Fibonacci sequence to delay responses, if the maximum allowed calls is exhausted, the route returns `429` status with a `Retry-After` header indicating how long a client should wait before attempting to call. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install hapi-brute 9 | ``` 10 | 11 | ## Unit tests 12 | 13 | ``` 14 | npm test 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### On preResponse 20 | In this mode the plugin is invoked early in the response cycle and requires no change to route handler. given that it is invoked early the plugin will determine brute attempts based on the `IP address` of the request **only**. 21 | #### example 22 | ``` 23 | server.route({ 24 | method: 'GET', 25 | path: '/1', 26 | config: {plugins: {brute: {preResponse: true}}, 27 | handler: function (request, reply) { 28 | return reply('ok'); 29 | }); 30 | ``` 31 | 32 | ### User invoked 33 | When you need to limit requests based on an arbitrary condition, for example the username someone is using to log in 34 | 35 | #### callback example 36 | ``` 37 | server.route({ 38 | method: 'GET', 39 | path: '/1', 40 | config: {plugins: {brute: true}}, 41 | handler: function (request, reply) { 42 | const user = request.auth.credentials.username; 43 | reply.brute('username', user, (err, reset)=> { 44 | if(validUser(user)) { 45 | // reset the counter for the user 46 | // after a valid attempt 47 | reset((err)=> { 48 | reply('welcome ' + username); 49 | }); 50 | } else { 51 | reply('Invalid username/password'); 52 | } 53 | }); 54 | } 55 | }); 56 | ``` 57 | #### promise example 58 | ``` 59 | server.route({ 60 | method: 'GET', 61 | path: '/1', 62 | config: {plugins: {brute: true}}, 63 | handler: function (request, reply) { 64 | const user = request.auth.credentials.username; 65 | reply.brute('username', user) 66 | .then((reset)=> { 67 | if(validUser(user)) { 68 | // reset the counter for the user 69 | // after a valid attempt 70 | return reset() 71 | .then(() => { 72 | reply('welcome ' + username); 73 | }); 74 | } else { 75 | reply('Invalid username/password'); 76 | } 77 | }); 78 | } 79 | }); 80 | ``` 81 | ### Plugin options 82 | 83 | ``` 84 | { 85 | allowedRetries: 5, // the number of attempts before the client gets a 429 response 86 | // the first attempt will see no delay the second will see 200ms delay 87 | // 3rd - 5th attempts will see longer delays calculated using a Fibonacci sequence 88 | initialWait: 200, // the initial delay the client will exhibit after the first attempt 89 | maxWait: 15000, // during the allowed retries, the delay will not exceed this value 90 | timeWindow: 6 * 60 * 1000, // once a client gets a 429, it has to wait for the amount of time to expire 91 | proxyCount: 0, // which proxy in the proxy list in the x-forwarded-for header should be used 92 | // 0 is disables considering proxies 93 | preResponse: false // should the plugin kick-in before before the route handler is invoked 94 | } 95 | ``` 96 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Joi = require('joi'); 3 | const Boom = require('boom'); 4 | 5 | const internals = {}; 6 | 7 | internals.schema = Joi.object().keys({ 8 | allowedRetries: Joi.number().positive().optional(), 9 | initialWait: Joi.number().positive().optional(), 10 | maxWait: Joi.number().positive().optional(), 11 | timeWindow: Joi.number().positive().optional(), 12 | proxyCount: Joi.number().positive().allow(0).optional(), 13 | preResponse: Joi.boolean().optional() 14 | }); 15 | 16 | internals.defaults = { 17 | allowedRetries: 5, 18 | initialWait: 100, 19 | maxWait: 15000, 20 | timeWindow: 6 * 60 * 1000, 21 | proxyCount: 0, 22 | preResponse: false 23 | }; 24 | internals.getIP = ( request, count ) => { 25 | 26 | if (count && request.headers['x-forwarded-for']) { 27 | const proxies = request.headers['x-forwarded-for'].split(',').filter((chunk) => chunk.trim()); 28 | if (proxies.length > count) { 29 | return proxies[proxies.length - count - 1].trim(); 30 | } 31 | if (proxies[0]) { 32 | return proxies[0].trim(); 33 | } 34 | 35 | } 36 | return request.info.remoteAddress; 37 | }; 38 | 39 | internals.delays = {}; 40 | internals.getDelays = (initialWait, allowedRetries, maxWait) => { 41 | 42 | if (internals.delays[initialWait] === undefined) { 43 | 44 | let next = initialWait; 45 | let newDelay = initialWait; 46 | internals.delays[initialWait] = []; 47 | for (let i = 0; i < allowedRetries; i++) { 48 | 49 | internals.delays[initialWait][i] = Math.min(newDelay, maxWait); 50 | newDelay += next; 51 | next = internals.delays[initialWait][i]; 52 | 53 | } 54 | } 55 | return internals.delays[initialWait]; 56 | }; 57 | 58 | internals.delay = function delay( key, value, request, settings, cache) { 59 | 60 | return new Promise((resolve, reject) => { 61 | 62 | let id = value; 63 | if (key === 'ip') { 64 | id = internals.getIP(request, settings.proxyCount); 65 | } 66 | cache.get(key + '-' + id, ( err, cached, stored, cacheInfo ) => { 67 | 68 | if (err) { 69 | return reject(Boom.internal(err)); 70 | } 71 | // new ip 72 | if (cached === null) { 73 | cache.set(key + '-' + id, { attemps: 0, lastAttemp: Date.now() }, 0, (err) => { 74 | 75 | if (err) { 76 | return reject(Boom.internal(err)); 77 | } 78 | return resolve({ continue: true }); 79 | }); 80 | } 81 | else { 82 | const delays = internals.getDelays(settings.initialWait, settings.allowedRetries, settings.maxWait); 83 | const remainingTime = delays[cached.attemps] - (Date.now() - cached.lastAttemp); 84 | cached.attemps += 1; 85 | if (cached.attemps > settings.allowedRetries) { 86 | reject({ 87 | err:{ 88 | statusCode: 429, 89 | error: 'Too Many Requests', 90 | message: 'you have exceeded your request limit' 91 | }, 92 | code: 429, 93 | header:{ key: 'Retry-After', val: cacheInfo.ttl } 94 | }); 95 | } 96 | cached.lastAttemp = Date.now() + remainingTime; 97 | cache.set(key + '-' + id, cached, 0, (err) => { 98 | 99 | if (err) { 100 | return reject(Boom.internal(err)); 101 | } 102 | if (remainingTime > 0) { 103 | // delay the response 104 | setTimeout(resolve, remainingTime); 105 | } 106 | else { 107 | return resolve(); 108 | } 109 | }); 110 | } 111 | }); 112 | }); 113 | }; 114 | 115 | internals.drop = function drop(key, request, settings, cache) { 116 | 117 | return new Promise((resolve, reject) => { 118 | 119 | cache.drop(key, (err) => { 120 | 121 | if (err) { 122 | return reject(Boom.internal(err)); 123 | } 124 | resolve(); 125 | }); 126 | }); 127 | }; 128 | 129 | exports.register = function (server, options, next) { 130 | 131 | let validateOptions = internals.schema.validate(options); 132 | if (validateOptions.error) { 133 | return next(validateOptions.error); 134 | } 135 | 136 | const settings = Object.assign({}, internals.defaults, options); 137 | const cache = server.cache({ segment: 'hapi-brute', expiresIn: settings.timeWindow }); 138 | 139 | server.decorate('reply', 'brute', function (key, val, cb) { 140 | 141 | const curKey = typeof key === 'string' && key || 'ip'; 142 | const callback = (typeof key === 'function' && key) || (typeof cb === 'function' && cb); 143 | // const promise = typeof key === 'object' && key.then ? key : null; 144 | let value = typeof val === 'string' && val || null; 145 | if (curKey !== 'ip' && value === null) { 146 | const err = Boom.internal('Must provide a value if key is not the default'); 147 | this.response(err); 148 | return Promise.reject(err); 149 | } 150 | let routeSettings = this.request.route.settings.plugins.brute; 151 | if (typeof routeSettings === 'object') { 152 | validateOptions = internals.schema.validate(routeSettings); 153 | if (validateOptions.error) { 154 | return this.response(validateOptions.error); 155 | } 156 | routeSettings = Object.assign({}, settings, routeSettings); 157 | } 158 | else { 159 | routeSettings = settings; 160 | } 161 | if (curKey === 'ip') { 162 | value = internals.getIP(this.request, routeSettings.proxyCount); 163 | } 164 | const reset = (resetCallback) => { 165 | 166 | internals.drop(curKey + '-' + value, this.request, routeSettings, cache) //eslint-disable-line 167 | .then(() => { 168 | 169 | try { 170 | resetCallback && resetCallback(); 171 | } 172 | catch (err) { 173 | this.response(err); 174 | return Promise.reject(err); 175 | } 176 | return Promise.resolve(); 177 | }, (err) => { 178 | 179 | this.response(err); 180 | return Promise.reject(err); 181 | }); 182 | }; 183 | return internals.delay(curKey, value, this.request, routeSettings, cache) 184 | .then(() => { 185 | 186 | try { 187 | callback && callback(null, reset); 188 | } 189 | catch (err) { 190 | this.response(err); 191 | return Promise.reject(err); 192 | } 193 | return Promise.resolve(reset); 194 | }, (result) => { 195 | 196 | if (result.code) { 197 | this.response(result.err).code(result.code).header(result.header.key, result.header.val); 198 | return Promise.reject(result.err); 199 | } 200 | this.response(result); 201 | return Promise.reject(result); 202 | }); 203 | }); 204 | 205 | server.ext('onPreAuth', (request, reply) => { 206 | 207 | if (!request.route.settings.plugins.brute) { 208 | return reply.continue(); 209 | } 210 | let routeOptions = request.route.settings.plugins.brute; 211 | if (typeof routeOptions === 'object') { 212 | validateOptions = internals.schema.validate(routeOptions); 213 | if (validateOptions.error) { 214 | return reply(validateOptions.error); 215 | } 216 | routeOptions = Object.assign({}, settings, routeOptions); 217 | } 218 | else { 219 | routeOptions = settings; 220 | } 221 | if (routeOptions.preResponse) { 222 | 223 | internals.delay('ip', null, request, routeOptions, cache) 224 | .then(() => (reply.continue()) 225 | , (result) => { 226 | 227 | if (result.code) { 228 | return reply(result.err).code(result.code).header(result.header.key, result.header.val); 229 | } 230 | return reply(result); 231 | }); 232 | } 233 | else { 234 | return reply.continue(); 235 | } 236 | }); 237 | 238 | return next(); 239 | }; 240 | 241 | exports.register.attributes = { 242 | name: 'brute', 243 | version: '1.0.0' 244 | }; 245 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Code = require('code'); 3 | const Lab = require('lab'); 4 | const Hapi = require('hapi'); 5 | const Brute = require('../'); 6 | // const Boom = require('boom'); 7 | const lab = exports.lab = Lab.script(); 8 | const describe = lab.describe; 9 | const it = lab.it; 10 | const expect = Code.expect; 11 | 12 | describe('Brute', () => { 13 | 14 | it('starts', (done) => { 15 | 16 | const server = new Hapi.Server(); 17 | server.connection(); 18 | server.route({ 19 | method: 'GET', 20 | path: '/1', 21 | config: { plugins: { brute: true } }, 22 | handler: function (request, reply) { 23 | 24 | expect(reply.brute).to.exist(); 25 | return reply('ok'); 26 | } 27 | }); 28 | server.register(Brute, (err) => { 29 | 30 | expect(err).to.not.exist(); 31 | server.start((err) => { 32 | 33 | expect(err).to.not.exist(); 34 | server.inject({ method: 'GET', url: '/1' }, (res) => { 35 | 36 | expect(res.statusCode).to.equal(200); 37 | expect(res.headers['retry-after']).to.not.exist(); 38 | done(); 39 | }); 40 | }); 41 | }); 42 | }); 43 | it('does not start if option not set', (done) => { 44 | 45 | const server = new Hapi.Server(); 46 | server.connection(); 47 | server.route({ 48 | method: 'GET', 49 | path: '/1', 50 | handler: function (request, reply) { 51 | 52 | return reply('ok'); 53 | } 54 | }); 55 | server.register(Brute, (err) => { 56 | 57 | expect(err).to.not.exist(); 58 | server.start((err) => { 59 | 60 | expect(err).to.not.exist(); 61 | server.inject({ method: 'GET', url: '/1' }, (res) => { 62 | 63 | expect(res.statusCode).to.equal(200); 64 | expect(res.headers['retry-after']).to.not.exist(); 65 | done(); 66 | }); 67 | }); 68 | }); 69 | }); 70 | it('errors if server cache not started', (done) => { 71 | 72 | const server = new Hapi.Server(); 73 | server.connection(); 74 | server.route({ 75 | method: 'GET', 76 | path: '/1', 77 | config: { plugins: { brute: true } }, 78 | handler: function (request, reply) { 79 | 80 | expect(reply.brute).to.exist(); 81 | return reply('ok'); 82 | } 83 | }); 84 | server.register({ register: Brute, options:{ preResponse: true } }, (err) => { 85 | 86 | expect(err).to.not.exist(); 87 | server.inject({ method: 'GET', url: '/1' }, (res) => { 88 | 89 | expect(res.statusCode).to.equal(500); 90 | expect(res.headers['retry-after']).to.not.exist(); 91 | done(); 92 | }); 93 | }); 94 | }); 95 | it('handles brute attempts - no proxy', (done) => { 96 | 97 | const server = new Hapi.Server(); 98 | server.connection(); 99 | server.route({ 100 | method: 'GET', 101 | path: '/1', 102 | config: { plugins: { brute: { preResponse: true } } }, 103 | handler: function (request, reply) { 104 | 105 | expect(reply.brute).to.exist(); 106 | return reply('ok'); 107 | } 108 | }); 109 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2, preResponse: true } }, (err) => { 110 | 111 | expect(err).to.not.exist(); 112 | server.start((err) => { 113 | 114 | expect(err).to.not.exist(); 115 | server.inject({ method: 'GET', url: '/1' }, (res) => { 116 | 117 | expect(res.statusCode).to.equal(200); 118 | expect(res.headers['retry-after']).to.not.exist(); 119 | let time = Date.now(); 120 | server.inject({ method: 'GET', url: '/1' }, (res) => { 121 | 122 | expect(res.statusCode).to.equal(200); 123 | expect(Date.now() - time).to.be.at.least(150); 124 | expect(res.headers['retry-after']).to.not.exist(); 125 | time = Date.now(); 126 | server.inject({ method: 'GET', url: '/1' }, (res) => { 127 | 128 | expect(res.statusCode).to.equal(200); 129 | expect(Date.now() - time).to.be.at.least(350); 130 | expect(res.headers['retry-after']).to.not.exist(); 131 | server.inject({ method: 'GET', url: '/1' }, (res) => { 132 | 133 | expect(res.statusCode).to.equal(429); 134 | expect(res.headers['retry-after']).to.be.at.least(359500); 135 | server.inject({ method: 'GET', url: '/1' }, (res) => { 136 | 137 | expect(res.statusCode).to.equal(429); 138 | expect(res.headers['retry-after']).to.be.at.least(359500); 139 | done(); 140 | }); 141 | }); 142 | }); 143 | }); 144 | }); 145 | }); 146 | }); 147 | }); 148 | it('handles proxies - count within limit', (done) => { 149 | 150 | const server = new Hapi.Server(); 151 | server.connection(); 152 | server.route({ 153 | method: 'GET', 154 | path: '/1', 155 | config: { plugins: { brute: true } }, 156 | handler: function (request, reply) { 157 | 158 | expect(reply.brute).to.exist(); 159 | return reply('ok'); 160 | } 161 | }); 162 | server.register({ register: Brute, options:{ initialWait: 20, allowedRetries:1, proxyCount: 2, preResponse: true } }, (err) => { 163 | 164 | expect(err).to.not.exist(); 165 | server.start((err) => { 166 | 167 | expect(err).to.not.exist(); 168 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.66, 129.78.64.103, 10.100.0.123' } }, (res) => { 169 | 170 | expect(res.statusCode).to.equal(200); 171 | expect(res.headers['retry-after']).to.not.exist(); 172 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.66, 129.78.64.103, 10.100.0.123' } }, (res) => { 173 | 174 | expect(res.statusCode).to.equal(200); 175 | expect(res.headers['retry-after']).to.not.exist(); 176 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.66, 129.78.64.103, 10.100.0.123' } }, (res) => { 177 | 178 | expect(res.statusCode).to.equal(429); 179 | expect(res.headers['retry-after']).to.be.at.least(30); 180 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.67, 129.78.64.103, 10.100.0.123' } }, (res) => { 181 | 182 | expect(res.statusCode).to.equal(200); 183 | expect(res.headers['retry-after']).to.not.exist(); 184 | done(); 185 | }); 186 | }); 187 | }); 188 | }); 189 | }); 190 | }); 191 | }); 192 | it('handles proxies - count out of limit', (done) => { 193 | 194 | const server = new Hapi.Server(); 195 | server.connection(); 196 | server.route({ 197 | method: 'GET', 198 | path: '/1', 199 | config: { plugins: { brute: true } }, 200 | handler: function (request, reply) { 201 | 202 | expect(reply.brute).to.exist(); 203 | return reply('ok'); 204 | } 205 | }); 206 | server.register({ register: Brute, options:{ initialWait: 20, allowedRetries:1, proxyCount: 4, preResponse: true } }, (err) => { 207 | 208 | expect(err).to.not.exist(); 209 | server.start((err) => { 210 | 211 | expect(err).to.not.exist(); 212 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.66, 129.78.64.103, 10.100.0.123' } }, (res) => { 213 | 214 | expect(res.statusCode).to.equal(200); 215 | expect(res.headers['retry-after']).to.not.exist(); 216 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.66, 129.78.64.103, 10.100.0.123' } }, (res) => { 217 | 218 | expect(res.statusCode).to.equal(200); 219 | expect(res.headers['retry-after']).to.not.exist(); 220 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.66, 129.78.64.103, 10.100.0.123' } }, (res) => { 221 | 222 | expect(res.statusCode).to.equal(429); 223 | expect(res.headers['retry-after']).to.be.at.least(30); 224 | server.inject({ method: 'GET', url: '/1', headers: { 'X-Forwarded-For': '129.78.138.67, 129.78.64.103, 10.100.0.123' } }, (res) => { 225 | 226 | expect(res.statusCode).to.equal(200); 227 | expect(res.headers['retry-after']).to.not.exist(); 228 | done(); 229 | }); 230 | }); 231 | }); 232 | }); 233 | }); 234 | }); 235 | }); 236 | it('resets attempts - as promised', (done) => { 237 | 238 | const server = new Hapi.Server({ debug: { request: ['error'] } }); 239 | let doReset = false; 240 | server.connection(); 241 | server.route({ 242 | method: 'GET', 243 | path: '/1', 244 | config: { plugins: { brute: true } }, 245 | handler: function (request, reply) { 246 | 247 | expect(reply.brute).to.exist(); 248 | if (doReset) { 249 | reply.brute() 250 | .then((reset) => (reset())) 251 | .then(() => { 252 | 253 | reply('didReset'); 254 | }); 255 | } 256 | else { 257 | reply('ok'); 258 | } 259 | } 260 | }); 261 | server.register({ register: Brute, options:{ initialWait: 20, allowedRetries:2, proxyCount: 2, preResponse: true } }, (err) => { 262 | 263 | expect(err).to.not.exist(); 264 | server.start((err) => { 265 | 266 | expect(err).to.not.exist(); 267 | server.inject({ method: 'GET', url: '/1' }, (res) => { 268 | 269 | expect(res.statusCode).to.equal(200); 270 | expect(res.headers['retry-after']).to.not.exist(); 271 | doReset = true; 272 | server.inject({ method: 'GET', url: '/1' }, (res) => { 273 | 274 | expect(res.statusCode).to.equal(200); 275 | expect(res.headers['retry-after']).to.not.exist(); 276 | server.inject({ method: 'GET', url: '/1' }, (res) => { 277 | 278 | expect(res.result).to.equal('didReset'); 279 | expect(res.statusCode).to.equal(200); 280 | expect(res.headers['retry-after']).to.not.exist(); 281 | server.inject({ method: 'GET', url: '/1' }, (res) => { 282 | 283 | expect(res.statusCode).to.equal(200); 284 | expect(res.headers['retry-after']).to.not.exist(); 285 | done(); 286 | }); 287 | }); 288 | }); 289 | }); 290 | }); 291 | }); 292 | }); 293 | it('resets attempts - with callbacks', (done) => { 294 | 295 | const server = new Hapi.Server({ debug: { request: ['error'] } }); 296 | let doReset = false; 297 | server.connection(); 298 | server.route({ 299 | method: 'GET', 300 | path: '/1', 301 | config: { plugins: { brute: true } }, 302 | handler: function (request, reply) { 303 | 304 | expect(reply.brute).to.exist(); 305 | if (doReset) { 306 | 307 | reply.brute((err, reset) => { 308 | 309 | reset(() => { 310 | 311 | reply('didReset'); 312 | }); 313 | }); 314 | } 315 | else { 316 | reply('ok'); 317 | } 318 | } 319 | }); 320 | server.register({ register: Brute, options:{ initialWait: 20, allowedRetries:2, proxyCount: 2, preResponse: true } }, (err) => { 321 | 322 | expect(err).to.not.exist(); 323 | server.start((err) => { 324 | 325 | expect(err).to.not.exist(); 326 | server.inject({ method: 'GET', url: '/1' }, (res) => { 327 | 328 | expect(res.statusCode).to.equal(200); 329 | expect(res.headers['retry-after']).to.not.exist(); 330 | doReset = true; 331 | server.inject({ method: 'GET', url: '/1' }, (res) => { 332 | 333 | expect(res.statusCode).to.equal(200); 334 | expect(res.headers['retry-after']).to.not.exist(); 335 | server.inject({ method: 'GET', url: '/1' }, (res) => { 336 | 337 | expect(res.result).to.equal('didReset'); 338 | expect(res.statusCode).to.equal(200); 339 | expect(res.headers['retry-after']).to.not.exist(); 340 | server.inject({ method: 'GET', url: '/1' }, (res) => { 341 | 342 | expect(res.statusCode).to.equal(200); 343 | expect(res.headers['retry-after']).to.not.exist(); 344 | done(); 345 | }); 346 | }); 347 | }); 348 | }); 349 | }); 350 | }); 351 | }); 352 | it('catches errors in callbacks', (done) => { 353 | 354 | const server = new Hapi.Server(); 355 | server.connection(); 356 | server.route({ 357 | method: 'GET', 358 | path: '/1', 359 | config: { plugins: { brute: true } }, 360 | handler: function (request, reply) { 361 | 362 | expect(reply.brute).to.exist(); 363 | 364 | reply.brute(() => { 365 | 366 | throw new Error('some error'); 367 | }); 368 | } 369 | }); 370 | server.register({ register: Brute, options:{ initialWait: 20, allowedRetries:2, proxyCount: 2, preResponse: true } }, (err) => { 371 | 372 | expect(err).to.not.exist(); 373 | server.start((err) => { 374 | 375 | expect(err).to.not.exist(); 376 | server.inject({ method: 'GET', url: '/1' }, (res) => { 377 | 378 | expect(res.statusCode).to.equal(500); 379 | expect(res.headers['retry-after']).to.not.exist(); 380 | done(); 381 | }); 382 | }); 383 | }); 384 | }); 385 | it('catches errors in reset callbacks', (done) => { 386 | 387 | const server = new Hapi.Server(); 388 | server.connection(); 389 | server.route({ 390 | method: 'GET', 391 | path: '/1', 392 | config: { plugins: { brute: true } }, 393 | handler: function (request, reply) { 394 | 395 | expect(reply.brute).to.exist(); 396 | reply.brute((err, reset) => { 397 | 398 | reset(() => { 399 | 400 | reply(badVar); 401 | }); 402 | }); 403 | } 404 | }); 405 | server.register({ register: Brute, options:{ initialWait: 20, allowedRetries:2, proxyCount: 2, preResponse: true } }, (err) => { 406 | 407 | expect(err).to.not.exist(); 408 | server.start((err) => { 409 | 410 | expect(err).to.not.exist(); 411 | server.inject({ method: 'GET', url: '/1' }, (res) => { 412 | 413 | expect(res.statusCode).to.equal(500); 414 | done(); 415 | }); 416 | }); 417 | }); 418 | }); 419 | it('honors route options', (done) => { 420 | 421 | const server = new Hapi.Server({ debug: { request: ['error'] } }); 422 | server.connection(); 423 | server.route({ 424 | method: 'GET', 425 | path: '/1', 426 | config: { plugins: { brute: { initialWait: 500 } } }, 427 | handler: function (request, reply) { 428 | 429 | expect(reply.brute).to.exist(); 430 | reply.brute() 431 | .then(() => { 432 | 433 | reply('ok'); 434 | }); 435 | } 436 | }); 437 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2 } }, (err) => { 438 | 439 | expect(err).to.not.exist(); 440 | server.start((err) => { 441 | 442 | expect(err).to.not.exist(); 443 | 444 | server.inject({ method: 'GET', url: '/1' }, (res) => { 445 | 446 | expect(res.statusCode).to.equal(200); 447 | expect(res.result).to.equal('ok'); 448 | 449 | const t = Date.now(); 450 | expect(res.headers['retry-after']).to.not.exist(); 451 | server.inject({ method: 'GET', url: '/1' }, (res) => { 452 | 453 | expect(res.statusCode).to.equal(200); 454 | expect(Date.now() - t).to.be.at.least(500); 455 | expect(res.headers['retry-after']).to.not.exist(); 456 | server.inject({ method: 'GET', url: '/1' }, (res) => { 457 | 458 | expect(res.statusCode).to.equal(200); 459 | expect(Date.now() - t).to.be.at.least(950); 460 | server.inject({ method: 'GET', url: '/1' }, (res) => { 461 | 462 | expect(res.statusCode).to.equal(429); 463 | expect(Date.now() - t).to.be.at.least(1390); 464 | expect(res.headers['retry-after']).to.be.at.least(10850); 465 | server.inject({ method: 'GET', url: '/1' }, (res) => { 466 | 467 | expect(res.statusCode).to.equal(429); 468 | expect(res.headers['retry-after']).to.be.at.least(359500); 469 | done(); 470 | }); 471 | }); 472 | }); 473 | }); 474 | }); 475 | }); 476 | }); 477 | }); 478 | it('fails on malformed options', (done) => { 479 | 480 | const server = new Hapi.Server(); 481 | server.connection(); 482 | server.route({ 483 | method: 'GET', 484 | path: '/1', 485 | config: { plugins: { brute: { initialWait: 'alot' } } }, 486 | handler: function (request, reply) { 487 | 488 | expect(reply.brute).to.exist(); 489 | reply.brute(new Promise((resolve, reject) => { 490 | 491 | resolve('ok'); 492 | })); 493 | } 494 | }); 495 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2 } }, (err) => { 496 | 497 | expect(err).to.not.exist(); 498 | server.start((err) => { 499 | 500 | expect(err).to.not.exist(); 501 | server.inject({ method: 'GET', url: '/1' }, (res) => { 502 | 503 | expect(res.statusCode).to.equal(500); 504 | done(); 505 | }); 506 | }); 507 | }); 508 | }); 509 | it('function can handle promises', (done) => { 510 | 511 | const server = new Hapi.Server({ debug: { request: ['error'] } }); 512 | server.connection(); 513 | server.route({ 514 | method: 'GET', 515 | path: '/1', 516 | config: { plugins: { brute: true } }, 517 | handler: function (request, reply) { 518 | 519 | expect(reply.brute).to.exist(); 520 | reply.brute() 521 | .then(() => (new Promise((resolve) => (resolve())))) 522 | .then(() => { 523 | 524 | reply('ok'); 525 | }); 526 | } 527 | }); 528 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2 } }, (err) => { 529 | 530 | expect(err).to.not.exist(); 531 | server.start((err) => { 532 | 533 | expect(err).to.not.exist(); 534 | server.inject({ method: 'GET', url: '/1' }, (res) => { 535 | 536 | expect(res.statusCode).to.equal(200); 537 | expect(res.result).to.equal('ok'); 538 | expect(res.headers['retry-after']).to.not.exist(); 539 | server.inject({ method: 'GET', url: '/1' }, (res) => { 540 | 541 | expect(res.statusCode).to.equal(200); 542 | const t = Date.now(); 543 | expect(res.headers['retry-after']).to.not.exist(); 544 | server.inject({ method: 'GET', url: '/1' }, (res) => { 545 | 546 | expect(res.statusCode).to.equal(200); 547 | expect(Date.now() - t).to.be.at.least(150); 548 | server.inject({ method: 'GET', url: '/1' }, (res) => { 549 | 550 | expect(res.statusCode).to.equal(429); 551 | expect(res.headers['retry-after']).to.be.at.least(350); 552 | server.inject({ method: 'GET', url: '/1' }, (res) => { 553 | 554 | expect(res.statusCode).to.equal(429); 555 | expect(res.headers['retry-after']).to.be.at.least(359500); 556 | done(); 557 | }); 558 | }); 559 | }); 560 | }); 561 | }); 562 | }); 563 | }); 564 | }); 565 | it('function can handle callbacks', (done) => { 566 | 567 | const server = new Hapi.Server({ debug: { request: ['error'] } }); 568 | server.connection(); 569 | server.route({ 570 | method: 'GET', 571 | path: '/1', 572 | config: { plugins: { brute: true } }, 573 | handler: function (request, reply) { 574 | 575 | expect(reply.brute).to.exist(); 576 | reply.brute(() => { 577 | 578 | reply('ok'); 579 | }); 580 | } 581 | }); 582 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2 } }, (err) => { 583 | 584 | expect(err).to.not.exist(); 585 | server.start((err) => { 586 | 587 | expect(err).to.not.exist(); 588 | server.inject({ method: 'GET', url: '/1' }, (res) => { 589 | 590 | expect(res.statusCode).to.equal(200); 591 | expect(res.result).to.equal('ok'); 592 | expect(res.headers['retry-after']).to.not.exist(); 593 | server.inject({ method: 'GET', url: '/1' }, (res) => { 594 | 595 | expect(res.statusCode).to.equal(200); 596 | const t = Date.now(); 597 | expect(res.headers['retry-after']).to.not.exist(); 598 | server.inject({ method: 'GET', url: '/1' }, (res) => { 599 | 600 | expect(res.statusCode).to.equal(200); 601 | expect(Date.now() - t).to.be.at.least(150); 602 | server.inject({ method: 'GET', url: '/1' }, (res) => { 603 | 604 | expect(res.statusCode).to.equal(429); 605 | expect(res.headers['retry-after']).to.be.at.least(350); 606 | server.inject({ method: 'GET', url: '/1' }, (res) => { 607 | 608 | expect(res.statusCode).to.equal(429); 609 | expect(res.headers['retry-after']).to.be.at.least(359500); 610 | done(); 611 | }); 612 | }); 613 | }); 614 | }); 615 | }); 616 | }); 617 | }); 618 | }); 619 | it('function respects keys and values', (done) => { 620 | 621 | const server = new Hapi.Server({ debug: { request: ['error'] } }); 622 | let username = 'john'; 623 | 624 | server.connection(); 625 | server.route({ 626 | method: 'GET', 627 | path: '/1', 628 | config: { plugins: { brute: true } }, 629 | handler: function (request, reply) { 630 | 631 | expect(reply.brute).to.exist(); 632 | reply.brute('username', username, () => { 633 | 634 | reply(username); 635 | }); 636 | } 637 | }); 638 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2 } }, (err) => { 639 | 640 | expect(err).to.not.exist(); 641 | server.start((err) => { 642 | 643 | expect(err).to.not.exist(); 644 | server.inject({ method: 'GET', url: '/1' }, (res) => { 645 | 646 | expect(res.statusCode).to.equal(200); 647 | expect(res.result).to.equal('john'); 648 | expect(res.headers['retry-after']).to.not.exist(); 649 | server.inject({ method: 'GET', url: '/1' }, (res) => { 650 | 651 | expect(res.statusCode).to.equal(200); 652 | const t = Date.now(); 653 | expect(res.headers['retry-after']).to.not.exist(); 654 | server.inject({ method: 'GET', url: '/1' }, (res) => { 655 | 656 | expect(res.statusCode).to.equal(200); 657 | expect(Date.now() - t).to.be.at.least(150); 658 | server.inject({ method: 'GET', url: '/1' }, (res) => { 659 | 660 | expect(res.statusCode).to.equal(429); 661 | expect(res.headers['retry-after']).to.be.at.least(350); 662 | username = 'otherJohn'; 663 | server.inject({ method: 'GET', url: '/1' }, (res) => { 664 | 665 | expect(res.statusCode).to.equal(200); 666 | expect(res.result).to.equal('otherJohn'); 667 | expect(res.headers['retry-after']).to.not.exist(); 668 | done(); 669 | }); 670 | }); 671 | }); 672 | }); 673 | }); 674 | }); 675 | }); 676 | }); 677 | it('function fails when invalid parameters are sent', (done) => { 678 | 679 | const server = new Hapi.Server(); 680 | const username = 'john'; 681 | 682 | server.connection(); 683 | server.route({ 684 | method: 'GET', 685 | path: '/1', 686 | config: { plugins: { brute: true } }, 687 | handler: function (request, reply) { 688 | 689 | expect(reply.brute).to.exist(); 690 | reply.brute('username', () => (username)); 691 | } 692 | }); 693 | server.register({ register: Brute, options:{ initialWait: 200, allowedRetries:2 } }, (err) => { 694 | 695 | expect(err).to.not.exist(); 696 | server.start((err) => { 697 | 698 | expect(err).to.not.exist(); 699 | server.inject({ method: 'GET', url: '/1' }, (res) => { 700 | 701 | expect(res.statusCode).to.equal(500); 702 | expect(res.result.statusCode).to.equal(500); 703 | done(); 704 | }); 705 | }); 706 | }); 707 | }); 708 | }); 709 | --------------------------------------------------------------------------------