├── media └── hapi-rate-limitor.png ├── .travis.yml ├── .github ├── dependabot.yml └── workflows │ └── run-tests.yml ├── .eslintrc.json ├── test ├── views │ └── rate-limit-exceeded.html ├── plugin-connects-to-redis.js ├── plugin-close-redis-connection.js ├── plugin-works-with-defaults.js ├── plugin-resets-rate-limit-after-timeout.js ├── plugin-sends-error-response-with-rate-limit-HTTP-repsonse-headers.js ├── plugin-works-with-other-plugins.js ├── plugin-allows-ip-whitelists.js ├── plugin-renders-view.js ├── plugin-allows-custom-ip-detection.js ├── plugin-uses-custom-extension-point.js ├── plugin-can-be-disabled.js ├── plugin-sends-success-response-rate-limit-HTTP-repsonse-headers.js ├── plugin-fires-rate-limited-event.js ├── plugin-allows-to-skip-rate-limiting.js ├── plugin-allows-user-specific-limits.js └── plugin-allows-route-specific-limits.js ├── .gitignore ├── LICENSE ├── lib ├── rate-limit.js ├── index.js ├── event-emitter.js └── rate-limiter.js ├── package.json ├── typings └── index.d.ts ├── CHANGELOG.md └── README.md /media/hapi-rate-limitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/futurestudio/hapi-rate-limitor/HEAD/media/hapi-rate-limitor.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 12 5 | - 14 6 | - node 7 | 8 | services: 9 | - redis-server 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "standard", 7 | "parserOptions": { 8 | "ecmaVersion": 2018 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/views/rate-limit-exceeded.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Rate Limit Exceeded 8 | 9 | 10 | 11 | 12 | 13 |

You’ve exceeded the rate limit.

14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | haters 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules 33 | jspm_packages 34 | package-lock.json 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional REPL history 40 | .node_repl_history 41 | 42 | # IDEA 43 | .idea 44 | 45 | # nyc coverage 46 | .nyc_output 47 | -------------------------------------------------------------------------------- /test/plugin-connects-to-redis.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test('Connects to Redis using a connection string', async (t) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib'), 11 | options: { 12 | redis: 'redis://localhost', 13 | namespace: `route-limits-${Date.now()}` 14 | } 15 | }) 16 | 17 | await server.start() 18 | await server.stop() 19 | 20 | t.pass() 21 | }) 22 | 23 | Test('Fails to connect to Redis', async (t) => { 24 | const server = new Hapi.Server() 25 | 26 | await t.throwsAsync( 27 | server.register({ 28 | plugin: require('../lib'), 29 | options: { 30 | redis: false, 31 | namespace: `route-limits-${Date.now()}` 32 | } 33 | }) 34 | ) 35 | }) 36 | -------------------------------------------------------------------------------- /test/plugin-close-redis-connection.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | const RateLimiter = require('../lib/rate-limiter') 6 | 7 | Test('Connects to Redis onPreStart and closes Redis connection onPostStop', async (t) => { 8 | const server = new Hapi.Server() 9 | 10 | await server.register({ 11 | plugin: require('../lib'), 12 | options: { 13 | namespace: `route-limits-${Date.now()}` 14 | } 15 | }) 16 | 17 | await server.start() 18 | await server.stop() 19 | 20 | t.pass() 21 | }) 22 | 23 | Test('Connect and disconnect from Redis on limiter start and stop', async (t) => { 24 | const server = { event: () => {} } 25 | const limiter = new RateLimiter(server) 26 | t.is(limiter.redis.status, 'wait') 27 | 28 | await limiter.start() 29 | t.is(limiter.redis.status, 'ready') 30 | 31 | await limiter.stop() 32 | }) 33 | -------------------------------------------------------------------------------- /test/plugin-works-with-defaults.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test.beforeEach('Create server with rate limit defaults', async ({ context }) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib'), 11 | options: { 12 | namespace: `route-limits-${Date.now()}` 13 | } 14 | }) 15 | 16 | await server.initialize() 17 | context.server = server 18 | }) 19 | 20 | Test('runs with rate limit defaults', async (t) => { 21 | const server = t.context.server 22 | 23 | server.route({ 24 | method: 'GET', 25 | path: '/', 26 | handler: () => { 27 | return 'This is rate limitoooooooor!' 28 | } 29 | }) 30 | 31 | const request = { 32 | url: '/', 33 | method: 'GET' 34 | } 35 | 36 | const response = await server.inject(request) 37 | t.is(response.statusCode, 200) 38 | t.is(response.headers['x-rate-limit-limit'], 60) 39 | t.is(response.headers['x-rate-limit-remaining'], 59) 40 | t.not(response.headers['x-rate-limit-reset'], null) 41 | }) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Future Studio (https://futurestud.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'README.md' 7 | 8 | concurrency: 9 | # the group name is composed of two elements: 10 | # 1. this workflow name "run-tests" 11 | # 2. the branch name retrieved via the "github.ref" variable 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [18.x, 20.x] 22 | redis-version: [6, 7] 23 | 24 | name: Node ${{ matrix.node-version }} - Redis ${{ matrix.redis-version }} 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | 34 | - name: Start Redis 35 | uses: supercharge/redis-github-action@1.1.0 36 | with: 37 | redis-version: ${{ matrix.redis-version }} 38 | 39 | - name: Install dependencies 40 | run: npm install 41 | 42 | - name: Run tests 43 | run: npm test 44 | env: 45 | CI: true 46 | -------------------------------------------------------------------------------- /lib/rate-limit.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class RateLimit { 4 | /** 5 | * Creates a new rate limit instance based on the rate-limiting object provided from the 6 | * [async-ratelimiter](https://github.com/microlinkhq/async-ratelimiter) package. 7 | * 8 | * @returns {Number} 9 | */ 10 | constructor (rateLimit) { 11 | this.rateLimit = rateLimit 12 | } 13 | 14 | /** 15 | * Returns the maximum allowed rate limit. 16 | * 17 | * @returns {Number} 18 | */ 19 | get total () { 20 | return this.rateLimit.total 21 | } 22 | 23 | /** 24 | * Returns the remaining rate limit. 25 | * 26 | * @returns {Number} 27 | */ 28 | get remaining () { 29 | return this.rateLimit.remaining - 1 30 | } 31 | 32 | /** 33 | * Returns the time since epoch in seconds when the rate limiting period will end. 34 | * 35 | * @returns {Number} 36 | */ 37 | get reset () { 38 | return this.rateLimit.reset 39 | } 40 | 41 | /** 42 | * Determine whether the rate limit quota is exceeded (has no remaining). 43 | * 44 | * @returns {Boolean} 45 | */ 46 | isInQuota () { 47 | return this.remaining >= 0 48 | } 49 | } 50 | 51 | module.exports = RateLimit 52 | -------------------------------------------------------------------------------- /test/plugin-resets-rate-limit-after-timeout.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | const timeout = async (ms) => { 7 | return new Promise(resolve => setTimeout(resolve, ms)) 8 | } 9 | 10 | Test.before(async ({ context }) => { 11 | const server = new Hapi.Server() 12 | 13 | await server.register({ 14 | plugin: require('../lib/index'), 15 | options: { 16 | max: 1, 17 | duration: 300, 18 | namespace: 'reset-response-headers' 19 | } 20 | }) 21 | 22 | server.route({ 23 | method: 'GET', 24 | path: '/', 25 | handler: () => { 26 | return 'This is rate limitoooooooor!' 27 | } 28 | }) 29 | 30 | await server.initialize() 31 | context.server = server 32 | }) 33 | 34 | Test('resets rate limit after window timeout', async (t) => { 35 | const request = { 36 | url: '/', 37 | method: 'GET' 38 | } 39 | 40 | const response = await t.context.server.inject(request) 41 | t.is(response.statusCode, 200) 42 | 43 | const response2 = await t.context.server.inject(request) 44 | t.is(response2.statusCode, 429) 45 | 46 | await timeout(300) 47 | 48 | const response3 = await t.context.server.inject(request) 49 | t.is(response3.statusCode, 200) 50 | }) 51 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const RateLimiter = require('./rate-limiter') 4 | 5 | async function register (server, options) { 6 | const { enabled = true, extensionPoint = 'onPostAuth', ...config } = options 7 | 8 | /** 9 | * Cut early if rate limiting is disabled. 10 | */ 11 | if (!enabled) { 12 | return 13 | } 14 | 15 | /** 16 | * This property stores the request’s rate limit 17 | * details for the response headers. 18 | */ 19 | server.decorate('request', 'rateLimit') 20 | 21 | const limiter = new RateLimiter(server, config) 22 | 23 | /** 24 | * Start the rate limiter before before the server. 25 | */ 26 | server.ext('onPreStart', async () => { 27 | await limiter.start() 28 | }) 29 | 30 | /** 31 | * Rate limit incoming requests. 32 | */ 33 | server.ext(extensionPoint, async (request, h) => { 34 | return limiter.handle(request, h) 35 | }) 36 | 37 | /** 38 | * Append rate-limit related headers to each 39 | * response, also to an error response. 40 | */ 41 | server.ext('onPreResponse', (request, h) => { 42 | return limiter.addHeaders(request, h) 43 | }) 44 | 45 | /** 46 | * Shut down rate limiter after server stop. 47 | */ 48 | server.ext('onPostStop', async () => { 49 | await limiter.stop() 50 | }) 51 | } 52 | 53 | exports.plugin = { 54 | register, 55 | once: true, 56 | pkg: require('../package.json') 57 | } 58 | -------------------------------------------------------------------------------- /test/plugin-sends-error-response-with-rate-limit-HTTP-repsonse-headers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test.before(async ({ context }) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib/index'), 11 | options: { 12 | max: 1, 13 | duration: 1000 * 15, 14 | namespace: `error-response-headers-${new Date()}` 15 | } 16 | }) 17 | 18 | server.route({ 19 | method: 'GET', 20 | path: '/', 21 | handler: () => { 22 | return 'This is rate limitoooooooor!' 23 | } 24 | }) 25 | 26 | await server.initialize() 27 | context.server = server 28 | }) 29 | 30 | Test('sends rate limit error and rate limit response headers', async (t) => { 31 | const request = { 32 | url: '/', 33 | method: 'GET' 34 | } 35 | 36 | const response1 = await t.context.server.inject(request) 37 | t.is(response1.statusCode, 200) 38 | t.is(response1.headers['x-rate-limit-limit'], 1) 39 | t.is(response1.headers['x-rate-limit-remaining'], 0) 40 | t.not(response1.headers['x-rate-limit-reset'], null) 41 | 42 | const response = await t.context.server.inject(request) 43 | t.is(response.statusCode, 429) 44 | t.is(response.headers['x-rate-limit-limit'], 1) 45 | t.is(response.headers['x-rate-limit-remaining'], 0) 46 | t.not(response.headers['x-rate-limit-reset'], null) 47 | }) 48 | -------------------------------------------------------------------------------- /test/plugin-works-with-other-plugins.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test('works with other plugins', async (t) => { 7 | const pluginA = { 8 | name: 'plugin-a', 9 | register: (server) => { 10 | server.ext('onPreResponse', (request, h) => { 11 | request.response.isBoom 12 | ? request.response.output.statusCode = 418 13 | : request.response.statusCode = 418 14 | 15 | return h.continue 16 | }) 17 | } 18 | } 19 | 20 | const server = new Hapi.Server() 21 | 22 | await server.register({ 23 | plugin: require('../lib'), 24 | options: { 25 | namespace: `route-limits-${Date.now()}` 26 | } 27 | }) 28 | await server.register({ plugin: pluginA }) 29 | 30 | await server.initialize() 31 | 32 | server.route({ 33 | method: 'GET', 34 | path: '/', 35 | handler: () => { 36 | throw new Error('This should create a 500 HTTP status, but we’re overwriting it in pluginA to status 418') 37 | } 38 | }) 39 | 40 | const request = { 41 | url: '/', 42 | method: 'GET' 43 | } 44 | 45 | // assert the response has the changed HTTP status code from pluginA and the rate limit headers from hapi-rate-limitor 46 | const response = await server.inject(request) 47 | t.is(response.statusCode, 418) 48 | t.is(response.headers['x-rate-limit-limit'], 60) 49 | t.is(response.headers['x-rate-limit-remaining'], 59) 50 | t.not(response.headers['x-rate-limit-reset'], null) 51 | }) 52 | -------------------------------------------------------------------------------- /test/plugin-allows-ip-whitelists.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test.beforeEach('Use route-specific rate limit,', async ({ context }) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib'), 11 | options: { 12 | max: 10, 13 | ipWhitelist: ['1.1.1.1'], 14 | namespace: `ip-whitelist-${Date.now()}` 15 | } 16 | }) 17 | 18 | await server.initialize() 19 | context.server = server 20 | }) 21 | 22 | Test('skips rate limiting for whitelisted IP address', async (t) => { 23 | const server = t.context.server 24 | 25 | server.route({ 26 | method: 'GET', 27 | path: '/whitelist', 28 | handler: () => 'This is rate limitoooooooor!' 29 | }) 30 | 31 | const request = { 32 | url: '/whitelist', 33 | method: 'GET', 34 | headers: { 35 | 'x-forwarded-for': '1.1.1.1' 36 | } 37 | } 38 | 39 | const response = await server.inject(request) 40 | t.is(response.statusCode, 200) 41 | t.is(response.headers['x-rate-limit-limit'], undefined) 42 | t.is(response.headers['x-rate-limit-remaining'], undefined) 43 | t.is(response.headers['x-rate-limit-reset'], undefined) 44 | }) 45 | 46 | Test('rate-limits non-whitelisted IP address', async (t) => { 47 | const server = t.context.server 48 | 49 | server.route({ 50 | method: 'GET', 51 | path: '/whitelist', 52 | handler: () => 'This is rate limitoooooooor!' 53 | }) 54 | 55 | const request = { 56 | url: '/whitelist', 57 | method: 'GET', 58 | headers: { 59 | 'x-forwarded-for': '4.4.4.4' 60 | } 61 | } 62 | 63 | const response = await server.inject(request) 64 | t.is(response.statusCode, 200) 65 | t.is(response.headers['x-rate-limit-limit'], 10) 66 | t.is(response.headers['x-rate-limit-remaining'], 9) 67 | t.truthy(response.headers['x-rate-limit-reset']) 68 | }) 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-rate-limitor", 3 | "description": "Rate limiting for hapi/hapi.js to prevent brute-force attacks", 4 | "version": "4.0.1", 5 | "author": "Marcus Pöhls ", 6 | "bugs": { 7 | "url": "https://github.com/futurestudio/hapi-rate-limitor/issues" 8 | }, 9 | "dependencies": { 10 | "@hapi/boom": "~10.0.1", 11 | "@supercharge/request-ip": "~1.2.0", 12 | "async-ratelimiter": "~1.6.1", 13 | "ioredis": "~5.8.0" 14 | }, 15 | "devDependencies": { 16 | "@hapi/basic": "~7.0.2", 17 | "@hapi/hapi": "~21.4.0", 18 | "@hapi/vision": "~7.0.3", 19 | "ava": "~5.3.0", 20 | "eslint": "~8.57.0", 21 | "eslint-config-standard": "~17.1.0", 22 | "eslint-plugin-import": "~2.32.0", 23 | "eslint-plugin-node": "~11.1.0", 24 | "eslint-plugin-promise": "~6.6.0", 25 | "eslint-plugin-standard": "~5.0.0", 26 | "handlebars": "~4.7.8", 27 | "nyc": "~17.1.0", 28 | "p-event": "~4.2.0" 29 | }, 30 | "engines": { 31 | "node": ">=18" 32 | }, 33 | "homepage": "https://github.com/futurestudio/hapi-rate-limitor#readme", 34 | "husky": { 35 | "hooks": { 36 | "pre-push": "npm run lint" 37 | } 38 | }, 39 | "keywords": [ 40 | "brute force", 41 | "brute force protection", 42 | "bruteforce", 43 | "hapi", 44 | "hapi.js", 45 | "hapijs", 46 | "limit", 47 | "plugin", 48 | "rate", 49 | "rate limit", 50 | "rate limiter", 51 | "rate limiting", 52 | "rate-limit" 53 | ], 54 | "license": "MIT", 55 | "main": "lib/index.js", 56 | "types": "typings", 57 | "repository": { 58 | "type": "git", 59 | "url": "git+https://github.com/futurestudio/hapi-rate-limitor.git" 60 | }, 61 | "scripts": { 62 | "coverage": "npm test", 63 | "lint": "eslint **/*.js", 64 | "lint:fix": "eslint **/*.js --fix", 65 | "test": "nyc ava", 66 | "test:single": "ava --match" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/event-emitter.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class RateLimitEventEmitter { 4 | /** 5 | * Create a new instance for the given `emitter` and `server`. 6 | * 7 | * @param {EventEmitter} emitter 8 | * @param {Hapi.Server} server 9 | */ 10 | constructor (emitter, server) { 11 | this.emitter = this.createEventEmitter(emitter, server) 12 | } 13 | 14 | /** 15 | * Returns an object of supported rate-limit events. 16 | * 17 | * @returns {Object} 18 | */ 19 | get events () { 20 | return { 21 | attempt: 'rate-limit:attempt', 22 | inQuota: 'rate-limit:in-quota', 23 | exceeded: 'rate-limit:exceeded' 24 | } 25 | } 26 | 27 | /** 28 | * Use the given event `emitter` if available or fall 29 | * back to the hapi server as the event emitter. 30 | * 31 | * @param {EventEmitter} emitter 32 | * @param {Hapi.Server} server 33 | * 34 | * @returns {Object} 35 | */ 36 | createEventEmitter (emitter, server) { 37 | if (emitter) { 38 | return emitter 39 | } 40 | 41 | Object 42 | .values(this.events) 43 | .forEach(name => server.event(name)) 44 | 45 | return server.events 46 | } 47 | 48 | /** 49 | * Fire the rate limit “attempt” event. 50 | * 51 | * @param {Request} request 52 | */ 53 | async fireAttemptEvent (request) { 54 | await this.emitter.emit(this.events.attempt, request) 55 | } 56 | 57 | /** 58 | * Fire the rate limit “in-quota” event. 59 | * 60 | * @param {Request} request 61 | */ 62 | async fireInQuotaEvent (request) { 63 | await this.emitter.emit(this.events.inQuota, request) 64 | } 65 | 66 | /** 67 | * Fire the rate limit “exceeded” event. 68 | * 69 | * @param {Request} request 70 | */ 71 | async fireExceededEvent (request) { 72 | await this.emitter.emit(this.events.exceeded, request) 73 | } 74 | } 75 | 76 | module.exports = RateLimitEventEmitter 77 | -------------------------------------------------------------------------------- /test/plugin-renders-view.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Path = require('path') 5 | const Hapi = require('@hapi/hapi') 6 | const Vision = require('@hapi/vision') 7 | const Handlebars = require('handlebars') 8 | 9 | async function setupServer (options) { 10 | const server = new Hapi.Server() 11 | 12 | await server.register([ 13 | { 14 | plugin: Vision 15 | }, 16 | { 17 | plugin: require('../lib'), 18 | options: Object.assign({ 19 | view: 'rate-limit-exceeded', 20 | max: 1, 21 | duration: 1000, 22 | namespace: `view-limits-${Date.now()}` 23 | }, options) 24 | }]) 25 | 26 | server.views({ 27 | engines: { 28 | html: Handlebars 29 | }, 30 | path: Path.resolve(__dirname, 'views') 31 | }) 32 | 33 | await server.initialize() 34 | 35 | return server 36 | } 37 | 38 | Test('plugin renders a view', async (t) => { 39 | const server = await setupServer() 40 | 41 | server.route({ 42 | method: 'GET', 43 | path: '/', 44 | handler: () => { 45 | return 'This is rate limitoooooooor!' 46 | } 47 | }) 48 | 49 | const request = { 50 | url: '/', 51 | method: 'GET' 52 | } 53 | 54 | const response1 = await server.inject(request) 55 | t.is(response1.statusCode, 200) 56 | t.is(response1.headers['x-rate-limit-limit'], 1) 57 | t.is(response1.headers['x-rate-limit-remaining'], 0) 58 | t.not(response1.headers['x-rate-limit-reset'], null) 59 | 60 | const response2 = await server.inject(request) 61 | t.is(response2.statusCode, 429) 62 | t.true(response2.payload.includes('You’ve exceeded the rate limit.')) 63 | t.is(response2.headers['x-rate-limit-limit'], 1) 64 | t.is(response2.headers['x-rate-limit-remaining'], 0) 65 | t.not(response2.headers['x-rate-limit-reset'], null) 66 | }) 67 | 68 | Test('plugin fails for missing view', async (t) => { 69 | await t.throwsAsync( 70 | setupServer({ view: 'not-existing-view' }) 71 | ) 72 | }) 73 | -------------------------------------------------------------------------------- /test/plugin-allows-custom-ip-detection.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test('uses getIp to detect the IP address', async (t) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib'), 11 | options: { 12 | max: 10, 13 | getIp: async (request) => { 14 | return request.headers['rate-limitor-ip'] 15 | }, 16 | namespace: `getip-${Date.now()}` 17 | } 18 | }) 19 | 20 | await server.initialize() 21 | 22 | server.route({ 23 | method: 'GET', 24 | path: '/getIp', 25 | handler: request => request.headers['rate-limitor-ip'] 26 | }) 27 | 28 | const request = { 29 | url: '/getIp', 30 | method: 'GET', 31 | headers: { 32 | 'rate-limitor-ip': '1.2.3.4' 33 | } 34 | } 35 | 36 | const response = await server.inject(request) 37 | t.is(response.statusCode, 200) 38 | t.is(response.result, '1.2.3.4') 39 | t.is(response.headers['x-rate-limit-limit'], 10) 40 | t.is(response.headers['x-rate-limit-remaining'], 9) 41 | t.truthy(response.headers['x-rate-limit-reset']) 42 | }) 43 | 44 | Test('falls back to request IP', async (t) => { 45 | const server = new Hapi.Server() 46 | 47 | await server.register({ 48 | plugin: require('../lib'), 49 | options: { 50 | max: 10, 51 | namespace: `getip-${Date.now()}` 52 | } 53 | }) 54 | 55 | await server.initialize() 56 | 57 | server.route({ 58 | method: 'GET', 59 | path: '/getIp', 60 | handler: request => request.headers['x-forwarded-for'] 61 | }) 62 | 63 | const request = { 64 | url: '/getIp', 65 | method: 'GET', 66 | headers: { 67 | 'x-forwarded-for': '4.4.4.4' 68 | } 69 | } 70 | 71 | const response = await server.inject(request) 72 | t.is(response.statusCode, 200) 73 | t.is(response.result, '4.4.4.4') 74 | t.is(response.headers['x-rate-limit-limit'], 10) 75 | t.is(response.headers['x-rate-limit-remaining'], 9) 76 | t.truthy(response.headers['x-rate-limit-reset']) 77 | }) 78 | -------------------------------------------------------------------------------- /test/plugin-uses-custom-extension-point.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test.beforeEach('Render view when rate limit is exceeded,', async ({ context }) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register(require('@hapi/basic')) 10 | 11 | await server.auth.strategy('basic', 'basic', { 12 | validate: () => { 13 | return { isValid: false } 14 | } 15 | }) 16 | 17 | context.server = server 18 | }) 19 | 20 | Test('plugin rate limits request onPreAuth', async (t) => { 21 | const server = t.context.server 22 | 23 | await server.register([ 24 | { 25 | plugin: require('../lib'), 26 | options: { 27 | max: 20, 28 | extensionPoint: 'onPreAuth', 29 | namespace: `view-limits-${Date.now()}` 30 | } 31 | } 32 | ]) 33 | 34 | server.route({ 35 | method: 'GET', 36 | path: '/', 37 | options: { 38 | auth: 'basic', 39 | handler: () => 'This is rate limitoooooooor!' 40 | } 41 | }) 42 | 43 | const request = { 44 | url: '/', 45 | method: 'GET' 46 | } 47 | 48 | const response = await server.inject(request) 49 | t.is(response.statusCode, 401) 50 | t.is(response.headers['x-rate-limit-limit'], 20) 51 | t.is(response.headers['x-rate-limit-remaining'], 19) 52 | t.not(response.headers['x-rate-limit-reset'], undefined) 53 | }) 54 | 55 | Test('plugin does not rate limit at onPostAuth because auth fails', async (t) => { 56 | const server = t.context.server 57 | 58 | await server.register([ 59 | { 60 | plugin: require('../lib'), 61 | options: { 62 | max: 20, 63 | extensionPoint: 'onPostAuth', 64 | namespace: `view-limits-${Date.now()}` 65 | } 66 | } 67 | ]) 68 | 69 | server.route({ 70 | method: 'GET', 71 | path: '/', 72 | options: { 73 | auth: 'basic', 74 | handler: () => 'This is rate limitoooooooor!' 75 | } 76 | }) 77 | 78 | const request = { 79 | url: '/', 80 | method: 'GET' 81 | } 82 | 83 | const response = await server.inject(request) 84 | 85 | t.is(response.statusCode, 401) 86 | t.is(response.headers['x-rate-limit-limit'], undefined) 87 | t.is(response.headers['x-rate-limit-remaining'], undefined) 88 | t.is(response.headers['x-rate-limit-reset'], undefined) 89 | }) 90 | -------------------------------------------------------------------------------- /test/plugin-can-be-disabled.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | async function initializeServer (options) { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib/index'), 11 | options: Object.assign({ 12 | namespace: `disabled-rate-limits-${Date.now()}` 13 | }, options) 14 | }) 15 | 16 | await server.initialize() 17 | 18 | return server 19 | } 20 | 21 | Test('Disable the rate limiting plugin', async (t) => { 22 | const server = await initializeServer({ enabled: false }) 23 | 24 | server.route({ 25 | method: 'GET', 26 | path: '/', 27 | handler: () => 'success' 28 | }) 29 | 30 | const request = { 31 | url: '/', 32 | method: 'GET' 33 | } 34 | 35 | const response = await server.inject(request) 36 | t.is(response.statusCode, 200) 37 | t.is(response.headers['x-rate-limit-limit'], undefined) 38 | t.is(response.headers['x-rate-limit-remaining'], undefined) 39 | t.is(response.headers['x-rate-limit-reset'], undefined) 40 | }) 41 | 42 | Test('Disable rate limiting on a route', async (t) => { 43 | const server = await initializeServer({ enabled: true }) 44 | 45 | server.route({ 46 | method: 'GET', 47 | path: '/', 48 | options: { 49 | plugins: { 50 | 'hapi-rate-limitor': { 51 | enabled: false 52 | } 53 | }, 54 | handler: () => 'success' 55 | } 56 | }) 57 | 58 | const requestDisabled = { 59 | url: '/', 60 | method: 'GET' 61 | } 62 | 63 | const responseDisabled = await server.inject(requestDisabled) 64 | t.is(responseDisabled.statusCode, 200) 65 | t.is(responseDisabled.headers['x-rate-limit-limit'], undefined) 66 | t.is(responseDisabled.headers['x-rate-limit-remaining'], undefined) 67 | t.is(responseDisabled.headers['x-rate-limit-reset'], undefined) 68 | 69 | server.route({ 70 | method: 'GET', 71 | path: '/enabled', 72 | options: { 73 | plugins: { 74 | 'hapi-rate-limitor': { 75 | max: 5000, 76 | enabled: true 77 | } 78 | }, 79 | handler: () => 'success' 80 | } 81 | }) 82 | 83 | const requestEnabled = { 84 | url: '/enabled', 85 | method: 'GET' 86 | } 87 | 88 | const responseEnabled = await server.inject(requestEnabled) 89 | t.is(responseEnabled.statusCode, 200) 90 | t.is(responseEnabled.headers['x-rate-limit-limit'], 5000) 91 | t.is(responseEnabled.headers['x-rate-limit-remaining'], 4999) 92 | t.not(responseEnabled.headers['x-rate-limit-reset'], null) 93 | }) 94 | -------------------------------------------------------------------------------- /test/plugin-sends-success-response-rate-limit-HTTP-repsonse-headers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test.before(async ({ context }) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib/index'), 11 | options: { 12 | max: 1000, 13 | duration: 5 * 1000, 14 | namespace: 'success-response-headers' 15 | } 16 | }) 17 | 18 | server.route({ 19 | method: 'GET', 20 | path: '/', 21 | handler: () => { 22 | return 'This is rate limitoooooooor!' 23 | } 24 | }) 25 | 26 | await server.initialize() 27 | context.server = server 28 | }) 29 | 30 | Test('succeeds a request and sends rate limit response headers', async (t) => { 31 | const request = { 32 | url: '/', 33 | method: 'GET' 34 | } 35 | 36 | const response = await t.context.server.inject(request) 37 | t.is(response.statusCode, 200) 38 | t.is(response.headers['x-rate-limit-limit'], 1000) 39 | t.is(response.headers['x-rate-limit-remaining'], 999) 40 | t.not(response.headers['x-rate-limit-reset'], null) 41 | }) 42 | 43 | Test('succeeds requests with different IP addresses and sends rate limit response headers', async (t) => { 44 | const first = { 45 | url: '/', 46 | method: 'GET', 47 | headers: { 48 | 'X-Client-IP': '1.2.3.4' 49 | } 50 | } 51 | 52 | const response1 = await t.context.server.inject(first) 53 | t.is(response1.statusCode, 200) 54 | t.is(response1.headers['x-rate-limit-limit'], 1000) 55 | t.is(response1.headers['x-rate-limit-remaining'], 999) 56 | t.not(response1.headers['x-rate-limit-reset'], null) 57 | 58 | const second = { 59 | url: '/', 60 | method: 'GET', 61 | headers: { 62 | 'X-Client-IP': '9.8.7.6' 63 | } 64 | } 65 | 66 | const response2 = await t.context.server.inject(second) 67 | t.is(response2.statusCode, 200) 68 | t.is(response2.headers['x-rate-limit-limit'], 1000) 69 | t.is(response2.headers['x-rate-limit-remaining'], 999) 70 | t.not(response2.headers['x-rate-limit-reset'], null) 71 | }) 72 | 73 | Test('succeeds a 404 request and sends rate limit response headers', async (t) => { 74 | const request = { 75 | url: '/404', 76 | method: 'GET', 77 | headers: { 78 | 'X-Client-IP': '11.22.33.44' 79 | } 80 | } 81 | 82 | const response = await t.context.server.inject(request) 83 | t.is(response.statusCode, 404) 84 | t.is(response.headers['x-rate-limit-limit'], undefined) 85 | t.is(response.headers['x-rate-limit-remaining'], undefined) 86 | t.is(response.headers['x-rate-limit-reset'], undefined) 87 | }) 88 | -------------------------------------------------------------------------------- /test/plugin-fires-rate-limited-event.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | const pEvent = require('p-event') 6 | const EventEmitter = require('events') 7 | 8 | const rejectIn1Sec = new Promise((resolve, reject) => { 9 | setTimeout(reject, 1000, new Error('rejected after 1s')) 10 | }) 11 | 12 | Test('falls back to server.events by default', async (t) => { 13 | const server = new Hapi.Server() 14 | 15 | await server.register({ 16 | plugin: require('../lib'), 17 | options: { 18 | namespace: `events-${Date.now()}` 19 | } 20 | }) 21 | 22 | await server.initialize() 23 | 24 | server.route({ 25 | method: 'GET', 26 | path: '/', 27 | handler: () => 'server.events is the emitter' 28 | }) 29 | 30 | const request = { 31 | url: '/', 32 | method: 'GET' 33 | } 34 | 35 | const events = [] 36 | 37 | server.events.on('rate-limit:attempt', request => events.push(request)) 38 | server.events.on('rate-limit:in-quota', request => events.push(request)) 39 | server.events.on('rate-limit:exceeded', request => events.push(request)) 40 | 41 | const response = await server.inject(request) 42 | t.is(response.statusCode, 200) 43 | 44 | t.is(events.length, 2) // should not contain the exceeded event 45 | }) 46 | 47 | Test('uses custom event emitter', async (t) => { 48 | const server = new Hapi.Server() 49 | const emitter = new EventEmitter() 50 | 51 | await server.register({ 52 | plugin: require('../lib'), 53 | options: { 54 | emitter, 55 | namespace: `events-${Date.now()}` 56 | } 57 | }) 58 | 59 | await server.initialize() 60 | 61 | server.route({ 62 | method: 'GET', 63 | path: '/', 64 | handler: () => 'custom event emitter' 65 | }) 66 | 67 | const request = { 68 | url: '/', 69 | method: 'GET' 70 | } 71 | 72 | const attempt = pEvent(emitter, 'rate-limit:attempt') 73 | const inQuota = pEvent(emitter, 'rate-limit:in-quota') 74 | 75 | const response = await server.inject(request) 76 | t.is(response.statusCode, 200) 77 | 78 | t.truthy(await attempt) 79 | t.truthy(await inQuota) 80 | }) 81 | 82 | Test('fires exceeded event', async (t) => { 83 | const server = new Hapi.Server() 84 | const emitter = new EventEmitter() 85 | 86 | await server.register({ 87 | plugin: require('../lib'), 88 | options: { 89 | max: 1, 90 | emitter, 91 | namespace: `events-${Date.now()}` 92 | } 93 | }) 94 | 95 | await server.initialize() 96 | 97 | server.route({ 98 | method: 'GET', 99 | path: '/', 100 | handler: () => 'custom event emitter' 101 | }) 102 | 103 | const request = { 104 | url: '/', 105 | method: 'GET' 106 | } 107 | 108 | const attempt = pEvent(emitter, 'rate-limit:attempt') 109 | const exceeded = pEvent(emitter, 'rate-limit:exceeded') 110 | 111 | // first request won’t exceed the rate limit 112 | const response = await server.inject(request) 113 | t.is(response.statusCode, 200) 114 | t.truthy(await attempt) 115 | await t.throwsAsync(Promise.race([exceeded, rejectIn1Sec])) 116 | 117 | // second request exceeds the limit 118 | await server.inject(request) 119 | t.truthy(Promise.race([exceeded, rejectIn1Sec])) 120 | }) 121 | -------------------------------------------------------------------------------- /test/plugin-allows-to-skip-rate-limiting.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | async function initializeServer () { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib/index'), 11 | options: { 12 | skip (request) { 13 | return request.path.includes('/admin') 14 | }, 15 | max: 100, 16 | namespace: `skip-rate-limiting-${Date.now()}` 17 | } 18 | }) 19 | 20 | await server.initialize() 21 | 22 | return server 23 | } 24 | 25 | Test('Skips rate limiting when skip() returns true', async (t) => { 26 | const server = await initializeServer() 27 | 28 | server.route({ 29 | method: 'GET', 30 | path: '/admin', 31 | handler: () => 'success' 32 | }) 33 | 34 | const request = { 35 | url: '/admin', 36 | method: 'GET' 37 | } 38 | 39 | const response = await server.inject(request) 40 | t.is(response.statusCode, 200) 41 | t.is(response.headers['x-rate-limit-limit'], undefined) 42 | t.is(response.headers['x-rate-limit-remaining'], undefined) 43 | t.is(response.headers['x-rate-limit-reset'], undefined) 44 | }) 45 | 46 | Test('Does not skip rate limiting when skip() returns false', async (t) => { 47 | const server = await initializeServer() 48 | 49 | server.route({ 50 | method: 'GET', 51 | path: '/', 52 | handler: () => 'success' 53 | }) 54 | 55 | const requestDisabled = { 56 | url: '/', 57 | method: 'GET' 58 | } 59 | 60 | const response = await server.inject(requestDisabled) 61 | t.is(response.statusCode, 200) 62 | t.is(response.headers['x-rate-limit-limit'], 100) 63 | t.is(response.headers['x-rate-limit-remaining'], 99) 64 | t.not(response.headers['x-rate-limit-reset'], undefined) 65 | }) 66 | 67 | Test('Skips rate limiting when skip() returns false, but not enabled on route', async (t) => { 68 | const server = await initializeServer() 69 | 70 | server.route({ 71 | method: 'GET', 72 | path: '/', 73 | options: { 74 | plugins: { 'hapi-rate-limitor': { enabled: false } }, 75 | handler: () => 'success' 76 | } 77 | }) 78 | 79 | const requestDisabled = { 80 | url: '/', 81 | method: 'GET' 82 | } 83 | 84 | const response = await server.inject(requestDisabled) 85 | t.is(response.statusCode, 200) 86 | t.is(response.headers['x-rate-limit-limit'], undefined) 87 | t.is(response.headers['x-rate-limit-remaining'], undefined) 88 | t.is(response.headers['x-rate-limit-reset'], undefined) 89 | }) 90 | 91 | Test('Skips rate limiting when enabled on route, but skip() returns true', async (t) => { 92 | const server = await initializeServer() 93 | 94 | server.route({ 95 | method: 'GET', 96 | path: '/admin', 97 | options: { 98 | plugins: { 'hapi-rate-limitor': { enabled: true } }, 99 | handler: () => 'success' 100 | } 101 | }) 102 | 103 | const requestDisabled = { 104 | url: '/admin', 105 | method: 'GET' 106 | } 107 | 108 | const response = await server.inject(requestDisabled) 109 | t.is(response.statusCode, 200) 110 | t.is(response.headers['x-rate-limit-limit'], undefined) 111 | t.is(response.headers['x-rate-limit-remaining'], undefined) 112 | t.is(response.headers['x-rate-limit-reset'], undefined) 113 | }) 114 | -------------------------------------------------------------------------------- /test/plugin-allows-user-specific-limits.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | async function initializeServer (options) { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib/index'), 11 | options: Object.assign({ 12 | max: 1000, 13 | duration: 25 * 1000, 14 | namespace: `user-limits-${Date.now()}` 15 | }, options) 16 | }) 17 | 18 | server.route({ 19 | method: 'GET', 20 | path: '/', 21 | handler: () => { 22 | return 'This is rate limitoooooooor!' 23 | } 24 | }) 25 | 26 | await server.initialize() 27 | 28 | return server 29 | } 30 | 31 | Test.beforeEach('Use user-specific rate limit,', async ({ context }) => { 32 | context.server = await initializeServer({ 33 | userAttribute: 'id', 34 | userLimitAttribute: 'rateLimit' 35 | }) 36 | }) 37 | 38 | Test('succeeds an authenticated request and uses user-specific limit', async (t) => { 39 | const request = { 40 | url: '/', 41 | method: 'GET', 42 | auth: { 43 | strategy: 'default', 44 | credentials: { 45 | id: 'marcus-user-limit-1', 46 | name: 'Marcus', 47 | rateLimit: 2500 48 | } 49 | } 50 | } 51 | 52 | const response = await t.context.server.inject(request) 53 | t.is(response.statusCode, 200) 54 | t.is(response.headers['x-rate-limit-limit'], 2500) 55 | t.is(response.headers['x-rate-limit-remaining'], 2499) 56 | t.not(response.headers['x-rate-limit-reset'], null) 57 | }) 58 | 59 | Test('succeeds an authenticated request without user-specific rate limit (fallback to default limit)', async (t) => { 60 | const request = { 61 | url: '/', 62 | method: 'GET', 63 | auth: { 64 | strategy: 'default', 65 | credentials: { 66 | id: 'marcus-user-limit-2', 67 | name: 'Marcus' 68 | } 69 | } 70 | } 71 | 72 | const response = await t.context.server.inject(request) 73 | t.is(response.statusCode, 200) 74 | t.is(response.headers['x-rate-limit-limit'], 1000) 75 | t.is(response.headers['x-rate-limit-remaining'], 999) 76 | t.not(response.headers['x-rate-limit-reset'], null) 77 | }) 78 | 79 | Test('applies user-specific rate limits even for chaning IPs', async (t) => { 80 | const credentials = { 81 | id: 'marcus-user-limit-3', 82 | name: 'Marcus', 83 | rateLimit: 5000 84 | } 85 | 86 | const request1 = { 87 | url: '/', 88 | method: 'GET', 89 | headers: { 90 | 'x-forwarded-for': '1.2.3.4' 91 | }, 92 | auth: { 93 | strategy: 'default', 94 | credentials 95 | } 96 | } 97 | 98 | const response = await t.context.server.inject(request1) 99 | t.is(response.statusCode, 200) 100 | t.is(response.headers['x-rate-limit-limit'], 5000) 101 | t.is(response.headers['x-rate-limit-remaining'], 4999) 102 | t.not(response.headers['x-rate-limit-reset'], null) 103 | 104 | const request2 = { 105 | url: '/', 106 | method: 'GET', 107 | headers: { 108 | 'x-forwarded-for': '5.6.7.8' 109 | }, 110 | auth: { 111 | strategy: 'default', 112 | credentials 113 | } 114 | } 115 | 116 | const response2 = await t.context.server.inject(request2) 117 | t.is(response2.statusCode, 200) 118 | t.is(response2.headers['x-rate-limit-limit'], 5000) 119 | t.is(response2.headers['x-rate-limit-remaining'], 4998) 120 | t.not(response2.headers['x-rate-limit-reset'], null) 121 | }) 122 | 123 | Test('use user-specific limits even without a userKey', async (t) => { 124 | const server = await initializeServer({ 125 | max: 100, 126 | userLimitAttribute: 'rateLimit' 127 | }) 128 | 129 | const request = { 130 | url: '/', 131 | method: 'GET', 132 | headers: { 133 | 'x-forwarded-for': '1.2.3.4' 134 | }, 135 | auth: { 136 | strategy: 'default', 137 | credentials: { 138 | name: 'Marcus', 139 | rateLimit: 2000 140 | } 141 | } 142 | } 143 | 144 | const response = await server.inject(request) 145 | t.is(response.statusCode, 200) 146 | t.is(response.headers['x-rate-limit-limit'], 2000) 147 | t.is(response.headers['x-rate-limit-remaining'], 1999) 148 | t.not(response.headers['x-rate-limit-reset'], null) 149 | }) 150 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Plugin, Request } from '@hapi/hapi'; 4 | 5 | 6 | declare module '@hapi/hapi' { 7 | interface Request { 8 | rateLimit: HapiRateLimitor.RateLimit; 9 | } 10 | } 11 | 12 | 13 | declare namespace HapiRateLimitor { 14 | interface RateLimit { 15 | /** 16 | * Returns the maximum allowed rate limit. 17 | * 18 | * @returns Returns the maximum allowed rate limit. 19 | */ 20 | total: number; 21 | 22 | /** 23 | * Returns the remaining rate limit. 24 | * 25 | * @returns Returns the remaining rate limit. 26 | */ 27 | remaining: number; 28 | 29 | /** 30 | * Returns the time since epoch in seconds when the rate limiting period will end. 31 | * 32 | * @returns Returns the time since epoch in seconds when the rate limiting period will end. 33 | */ 34 | reset: number; 35 | 36 | /** 37 | * Determine whether the rate limit quota is exceeded (has no remaining). 38 | * 39 | * @returns Returns `true` if the rate limit is not exceeded, otherwise `false`. 40 | */ 41 | isInQuota(): boolean; 42 | } 43 | 44 | 45 | /** 46 | * Available options when registering hapi-rate-limitor as a plugin to the hapi server. 47 | */ 48 | interface Options { 49 | /** 50 | * The maximum number of requests allowed in a `duration`. 51 | * 52 | */ 53 | max?: number; 54 | 55 | /** 56 | * The lifetime window keeping records of a request in milliseconds. 57 | * 58 | */ 59 | duration?: number; 60 | 61 | /** 62 | * The used prefix to create the rate limit identifier before storing the data. 63 | * 64 | */ 65 | namespace?: string; 66 | 67 | /** 68 | * The Redis configuration used to create and connect an ioRedis instance. 69 | * The configuration value can be a connection string or an object. 70 | * This property is passed through to ioRedis. 71 | * 72 | */ 73 | redis?: string | object; 74 | 75 | /** 76 | * The [request lifecycle extension point](https://futurestud.io/downloads/hapi/request-lifecycle) used for rate limiting 77 | * 78 | */ 79 | extensionPoint?: string; 80 | 81 | /** 82 | * the property name identifying a user (credentials) for dynamic rate limits (see Readme). 83 | * This option is used to access the value from `request.auth.credentials`. 84 | * 85 | */ 86 | userAttribute?: string; 87 | 88 | /** 89 | * The property name identifying the rate limit value on dynamic rate limit (see Readme). 90 | * This option is used to access the value from `request.auth.credentials`. 91 | * 92 | */ 93 | userLimitAttribute?: string; 94 | 95 | /** 96 | * The path to a view file which will be rendered instead of throwing an error. 97 | * The rate limiter uses `h.view(yourView, { total, remaining, reset }).code(429)` 98 | * to render the defined view. 99 | * 100 | */ 101 | view?: string; 102 | 103 | /** 104 | * A shortcut to enable or disable the plugin, e.g. when running tests. 105 | * 106 | */ 107 | enabled?: boolean; 108 | 109 | /** 110 | * An async function with the signature `async (request)` to determine whether 111 | * to skip rate limiting for a given request. The `skip` function retrieves 112 | * the incoming request as the only argument. 113 | * 114 | */ 115 | skip?(request: Request): Promise; 116 | 117 | /** 118 | * An array of whitelisted IP addresses that won’t be rate-limited. Requests from 119 | * such IPs proceed the request lifecycle. Notice that the related responses 120 | * won’t contain rate limit headers. 121 | * 122 | */ 123 | ipWhitelist?: Array; 124 | 125 | /** 126 | * An async function with the signature `async (request)` to manually determine 127 | * the requesting IP address. This is helpful if your load balancer provides 128 | * the client IP address as the last item in the list of forwarded 129 | * addresses (e.g. Heroku and AWS ELB). 130 | * 131 | */ 132 | getIp?(request: Request): Promise; 133 | 134 | /** 135 | * an event emitter instance used to emit the 136 | * [rate-limitting events](https://github.com/futurestudio/hapi-rate-limitor#events) 137 | * 138 | */ 139 | emitter?: object; 140 | } 141 | } 142 | 143 | declare var HapiRateLimitor: Plugin; 144 | 145 | export = HapiRateLimitor; 146 | -------------------------------------------------------------------------------- /test/plugin-allows-route-specific-limits.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('ava') 4 | const Hapi = require('@hapi/hapi') 5 | 6 | Test.beforeEach('Use route-specific rate limit,', async ({ context }) => { 7 | const server = new Hapi.Server() 8 | 9 | await server.register({ 10 | plugin: require('../lib'), 11 | options: { 12 | max: 1000, 13 | duration: 1000, 14 | namespace: `route-limits-${Date.now()}`, 15 | userAttribute: 'id', 16 | userLimitAttribute: 'rateLimit' 17 | } 18 | }) 19 | 20 | await server.initialize() 21 | context.server = server 22 | }) 23 | 24 | Test('uses the route-specific limit', async (t) => { 25 | const server = t.context.server 26 | 27 | server.route({ 28 | method: 'GET', 29 | path: '/route-limit', 30 | options: { 31 | plugins: { 32 | 'hapi-rate-limitor': { 33 | max: 10, 34 | duration: 1000 * 60 35 | } 36 | }, 37 | handler: () => 'This is rate limitoooooooor!' 38 | } 39 | }) 40 | 41 | const request = { 42 | url: '/route-limit', 43 | method: 'GET' 44 | } 45 | 46 | const response = await server.inject(request) 47 | t.is(response.statusCode, 200) 48 | t.is(response.headers['x-rate-limit-limit'], 10) 49 | t.is(response.headers['x-rate-limit-remaining'], 9) 50 | t.not(response.headers['x-rate-limit-reset'], null) 51 | }) 52 | 53 | Test('route limit is not affected by server-limit', async (t) => { 54 | const server = t.context.server 55 | 56 | server.route([ 57 | { 58 | method: 'GET', 59 | path: '/', 60 | handler: () => 'Home' 61 | }, 62 | { 63 | method: 'GET', 64 | path: '/route-limit', 65 | options: { 66 | plugins: { 67 | 'hapi-rate-limitor': { 68 | max: 20, 69 | duration: 1000 * 60 70 | } 71 | }, 72 | handler: () => 'This is rate limitoooooooor!' 73 | } 74 | } 75 | ]) 76 | 77 | // at first, send a request against the GET / route with the global server-wide rate limit 78 | const homeResponse = await server.inject('/') 79 | t.is(homeResponse.headers['x-rate-limit-limit'], 1000) 80 | t.is(homeResponse.headers['x-rate-limit-remaining'], 999) 81 | 82 | // now go ahead and send a request against the route with a route-level max attempts config 83 | const request = { 84 | url: '/route-limit', 85 | method: 'GET' 86 | } 87 | 88 | const response = await server.inject(request) 89 | t.is(response.statusCode, 200) 90 | t.is(response.headers['x-rate-limit-limit'], 20) 91 | t.is(response.headers['x-rate-limit-remaining'], 19) 92 | t.not(response.headers['x-rate-limit-reset'], null) 93 | 94 | const secondResponse = await server.inject(request) 95 | t.is(secondResponse.headers['x-rate-limit-remaining'], 18) 96 | 97 | // the global max attempts for the server should not be affected by the requests 98 | // against a route with route-level max attempts 99 | const secondHomeResponse = await server.inject('/') 100 | t.is(secondHomeResponse.headers['x-rate-limit-limit'], 1000) 101 | t.is(secondHomeResponse.headers['x-rate-limit-remaining'], 998) 102 | }) 103 | 104 | Test('succeeds an authenticated request with route-specific rate limit and uses the route-limit, not default limit', async (t) => { 105 | const server = t.context.server 106 | 107 | server.route({ 108 | method: 'GET', 109 | path: '/route-limit-overrides-user-limit', 110 | options: { 111 | plugins: { 112 | 'hapi-rate-limitor': { 113 | max: 10, 114 | duration: 60 * 1000 // 60s 115 | } 116 | }, 117 | handler: () => 'This is rate limitoooooooor!' 118 | } 119 | }) 120 | 121 | const request = { 122 | url: '/route-limit-overrides-user-limit', 123 | method: 'GET', 124 | auth: { 125 | strategy: 'default', 126 | credentials: { 127 | id: 'marcus-route-limit-1', 128 | name: 'Marcus' 129 | } 130 | } 131 | } 132 | 133 | const response = await server.inject(request) 134 | t.is(response.statusCode, 200) 135 | t.is(response.headers['x-rate-limit-limit'], 10) 136 | t.is(response.headers['x-rate-limit-remaining'], 9) 137 | t.not(response.headers['x-rate-limit-reset'], null) 138 | }) 139 | 140 | Test('succeeds an authenticated request with user-specific limit and uses the route-limit, not user-limit', async (t) => { 141 | const server = t.context.server 142 | 143 | server.route({ 144 | method: 'GET', 145 | path: '/route-limit-overrides-default-limit', 146 | options: { 147 | plugins: { 148 | 'hapi-rate-limitor': { 149 | max: 15, 150 | duration: 60 * 1000 // 60s 151 | } 152 | }, 153 | handler: () => 'This is rate limitoooooooor!' 154 | } 155 | }) 156 | 157 | const request = { 158 | url: '/route-limit-overrides-default-limit', 159 | method: 'GET', 160 | auth: { 161 | strategy: 'default', 162 | credentials: { 163 | id: 'marcus-route-limit-1', 164 | limit: 123, 165 | name: 'Marcus', 166 | userAttribute: 'id', 167 | userLimitAttribute: 'limit' 168 | } 169 | } 170 | } 171 | 172 | const response = await server.inject(request) 173 | t.is(response.statusCode, 200) 174 | t.is(response.headers['x-rate-limit-limit'], 15) 175 | t.is(response.headers['x-rate-limit-remaining'], 14) 176 | t.not(response.headers['x-rate-limit-reset'], null) 177 | }) 178 | 179 | Test('does not change the default userIdKey config when set on routes', async (t) => { 180 | const server = t.context.server 181 | const url = '/route-limit-does-not-touch-identifiers' 182 | 183 | server.route({ 184 | method: 'GET', 185 | path: url, 186 | options: { 187 | plugins: { 188 | 'hapi-rate-limitor': { 189 | max: 10, 190 | duration: 60 * 1000, // 60s 191 | userIdKey: 'id' 192 | } 193 | }, 194 | handler: () => { 195 | return 'This is rate limitoooooooor!' 196 | } 197 | } 198 | }) 199 | 200 | const request = { 201 | url, 202 | method: 'GET', 203 | auth: { 204 | strategy: 'default', 205 | credentials: { 206 | id: 'marcus-route-limit-2', 207 | name: 'Marcus', 208 | rateLimit: 10000 209 | } 210 | } 211 | } 212 | 213 | const response1 = await server.inject(request) 214 | t.is(response1.statusCode, 200) 215 | t.is(response1.headers['x-rate-limit-limit'], 10) 216 | t.is(response1.headers['x-rate-limit-remaining'], 9) 217 | t.not(response1.headers['x-rate-limit-reset'], null) 218 | 219 | /** 220 | * A second route should identify a user the same way as defined 221 | * in the default settings. That means, a route config for 222 | * `userIdKey` does not affect the handling. 223 | */ 224 | server.route({ 225 | method: 'GET', 226 | path: `${url}-2`, 227 | options: { 228 | plugins: { 229 | 'hapi-rate-limitor': { 230 | max: 10, 231 | duration: 60 * 1000, // 60s 232 | userIdKey: 'name' 233 | } 234 | }, 235 | handler: () => { 236 | return 'This is rate limitoooooooor!' 237 | } 238 | } 239 | }) 240 | 241 | const request2 = { 242 | url: `${url}-2`, 243 | method: 'GET', 244 | auth: { 245 | strategy: 'default', 246 | credentials: { 247 | id: 'marcus-route-limit-2', 248 | name: 'Marcus-2', 249 | rateLimit: 25000 250 | } 251 | } 252 | } 253 | 254 | const response2 = await server.inject(request2) 255 | t.is(response2.statusCode, 200) 256 | t.is(response2.headers['x-rate-limit-limit'], 10) 257 | t.is(response2.headers['x-rate-limit-remaining'], 9) 258 | t.not(response2.headers['x-rate-limit-reset'], null) 259 | }) 260 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## [4.0.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v3.1.2...v4.0.0) - 2024-03-25 5 | 6 | ### Added 7 | - call `h.continue` after adding the rate-limit related response headers: this proceeds the plugin chain and plugins registered after hapi-rate-limitor can do their handling as well 8 | 9 | ### Updated 10 | - bump dependencies 11 | 12 | ### Breaking Changes 13 | This release drops support for Node.js v12. Please use Node.js v14 and later. 14 | 15 | 16 | ## [3.1.2](https://github.com/futurestudio/hapi-rate-limitor/compare/v3.1.1...v3.1.2) - 2022-02-15 17 | 18 | ### Updated 19 | - bump dependencies 20 | - minor code refinements 21 | - great to see a release after 1.5 years of silence 🥳 22 | 23 | 24 | ## [3.1.1](https://github.com/futurestudio/hapi-rate-limitor/compare/v3.1.0...v3.1.1) - 2020-08-05 25 | 26 | ### Updated 27 | - bump dependencies 28 | - minor code refinements 29 | - replaced `request-ip` dependency with `@supercharge/request-ip` providing improved request IP detection 30 | 31 | 32 | ## [3.1.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v3.0.0...v3.1.0) - 2020-06-01 33 | 34 | ### Updated 35 | - refined route-specific rate limit handling 36 | - bump dependencies 37 | 38 | ### Possible Breaking Changes 39 | This release introduces an updated handling route-level max attempts. 40 | 41 | **Previously**, the default (server-wide) rate limit affected route-level rate limits. 42 | **Now**, the route-level rate limits are independend and not affected by the default rate limit. 43 | 44 | Example: you have a `/login` route with `{ max: 10 }` configuration and your default configuration is `{ max: 60 }`. In the previous version, any request to other pages than `/login` would affect the max limit of 10 requests for the `/login` route. This behavior may have eaten all 10 requests already before even visiting the `/login` route. This new version handles the `/login` route independently from other pages because it has its own `max` configuration. 45 | 46 | This changed handling may introduce a breaking change for your app if you previously worked around that issue. Sorry, if I’m causing you trouble. I’m releasing this version as a minor release in the `2.x` and `3.x` release lines. In case you’re using tilde (`~`) in your `package.json` file, you’re not directly updated to this version when running `npm install`. 47 | 48 | 49 | ## [3.0.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.12.0...v3.0.0) - 2020-01-10 50 | 51 | ### Updated 52 | - bump dependencies 53 | - refined description in `package.json` 54 | 55 | ### Breaking Changes 56 | - require Node.js v12 57 | - this change aligns with the hapi ecosystem requiring Node.js v12 with the release of hapi 19 58 | 59 | 60 | ## [2.13.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.12.0...v2.13.0) - 2020-06-01 61 | 62 | ### Updated 63 | - refined route-specific rate limit handling 64 | 65 | 66 | ### Possible Breaking Changes 67 | This release introduces an updated handling route-level max attempts. 68 | 69 | **Previously**, the default (server-wide) rate limit affected route-level rate limits. 70 | **Now**, the route-level rate limits are independend and not affected by the default rate limit. 71 | 72 | Example: you have a `/login` route with `{ max: 10 }` configuration and your default configuration is `{ max: 60 }`. In the previous version, any request to other pages than `/login` would affect the max limit of 10 requests for the `/login` route. This behavior may have eaten all 10 requests already before even visiting the `/login` route. This new version handles the `/login` route independently from other pages because it has its own `max` configuration. 73 | 74 | This changed handling may introduce a breaking change for your app if you previously worked around that issue. Sorry, if I’m causing you trouble. I’m releasing this version as a minor release in the `2.x` and `3.x` release lines. In case you’re using tilde (`~`) in your `package.json` file, you’re not directly updated to this version when running `npm install`. 75 | 76 | 77 | ## [2.12.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.11.0...v2.12.0) - 2019-11-22 78 | 79 | ### Added 80 | - Travis testing for Node v13 81 | - TypeScript definitions for the rate limit request decoration and plugin options: this allows autocompletion in your editor (at least in VS Code :)) 82 | 83 | ### Updated 84 | - bump dependencies 85 | - internal refactorings: move event emitter to a dedicated class 86 | - internal refactorings: move rate limit data to a dedicated class 87 | 88 | ### Removed 89 | - `lodash` as a dependency 90 | - `@hapi/hoek` as a devDependency 91 | 92 | 93 | ## [2.11.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.10.0...v2.11.0) - 2019-10-17 94 | 95 | ### Added 96 | - basic TypeScript declarations in `lib/index.d.ts` 97 | 98 | 99 | ## [2.10.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.9.0...v2.10.0) - 2019-10-10 100 | 101 | ### Added 102 | - `getIp` option allowing you to manually determine the IP address from the request. 103 | - Example: 104 | ```js 105 | await server.register({ 106 | plugin: require('hapi-rate-limitor'), 107 | options: { 108 | getIp: async (request) => { 109 | const ips = request.headers['x-forwarded-for'].split(',') 110 | 111 | return ips[ips.length - 1] 112 | } 113 | } 114 | } 115 | ``` 116 | - `emitter` option to pass in your custom event emitter 117 | - dispatch rate limiting events: `rate-limit:attempt`, `rate-limit:in-quota`, `rate-limit:exceeded` 118 | - every event listener receives the request as the only argument 119 | 120 | 121 | ## [2.9.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.8.0...v2.9.0) - 2019-08-13 122 | 123 | ### Added 124 | - add `ipWhitelist` option representing an array of IP addresses that will skip rate limiting 125 | 126 | ### Updated 127 | - bump dependencies 128 | - update NPM scripts 129 | - minor code refinements 130 | 131 | ### Removed 132 | - Travis testing for Node.js version 11 133 | 134 | 135 | ## [2.8.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.7.1...v2.8.0) - 2019-06-25 136 | 137 | ### Added 138 | - support for Redis connection string, like `redis: 'redis://user:pass@dokku-redis-lolipop:6379'` (Thank you Rob! [PR #37](https://github.com/futurestudio/hapi-rate-limitor/pull/37)) 139 | 140 | ### Updated 141 | - minor code refinements 142 | - bump dependencies 143 | 144 | 145 | ## [2.7.1](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.7.0...v2.7.1) - 2019-05-10 146 | 147 | ### Updated 148 | - update to `@hapi/boom` from `boom` 149 | - test Node.js v12 150 | - bump dependencies 151 | 152 | 153 | ## [2.7.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.6.1...v2.7.0) - 2019-05-04 154 | 155 | ### Added 156 | - ensure a user-defined view exists on server start, otherwise throw an error 157 | 158 | ### Updated 159 | - bump dependencies 160 | - minor internal refactorings 161 | 162 | 163 | ## [2.6.1](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.6.0...v2.6.1) - 2019-04-27 164 | 165 | ### Updated 166 | - bump dependencis 167 | - update to hapi scoped dependencies 168 | 169 | 170 | ## [2.6.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.5.3...v2.6.0) - 2019-02-28 171 | 172 | ### Added 173 | - wait for Redis connection `onPreStart` 174 | - close Redis connection `onPostStop` 175 | 176 | 177 | ## [2.5.3](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.5.2...v2.5.3) - 2019-02-18 178 | 179 | ### Updated 180 | - bump dependencies 181 | - fix badges in Readme 182 | - Changelog: rename GitHub references `fs-opensource -> futurestudio` 183 | 184 | 185 | ## [2.5.2](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.5.1...v2.5.2) - 2019-01-26 186 | 187 | ### Updated 188 | - Readme: rename GitHub references `fs-opensource -> futurestudio` 189 | 190 | 191 | ## [2.5.1](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.5.0...v2.5.1) - 2019-01-22 192 | 193 | ### Updated 194 | - update tests for hapi 18 195 | - bump dependencies 196 | 197 | 198 | ## [2.5.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.4.0...v2.5.0) - 2019-01-16 199 | 200 | ### Added 201 | - plugin option `skip`: a function that determines whether to skip rate limiting for a request 202 | 203 | ### Updated 204 | - bump dependencies 205 | 206 | 207 | ## [2.4.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.3.0...v2.4.0) - 2018-12-12 208 | 209 | ### Added 210 | - plugin option `extensionPoint`: [request lifecycle extension point](https://futurestud.io/downloads/hapi/request-lifecycle) when the plugin should apply rate limiting 211 | 212 | ### Updated 213 | - bump dependencies 214 | - refined plugin options overview in Readme 215 | - improved formatting of code examples in Readme 216 | 217 | 218 | ## [2.3.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.2.0...v2.3.0) - 2018-10-29 219 | 220 | ### Added 221 | - `enabled` plugin option: allows you to disable the plugin, e.g. when running tests 222 | - `enabled` route option: disable the plugin for individual routes that would eat up the user’s rate limit, e.g. assets 223 | 224 | ### Updated 225 | - test for Node.js 11 226 | 227 | 228 | ## [2.2.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.1.0...v2.2.0) - 2018-10-21 229 | 230 | ### Updated 231 | - extract ID from authenticated requests even without user limit 232 | - extract user limit even without user identifier 233 | - apply user’s max on routes with rate limit config 234 | - bump dependencies 235 | 236 | 237 | ## [2.1.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.0.1...v2.1.0) - 2018-09-30 238 | 239 | ### Added 240 | - [render a rate limit exceeded `view`](https://github.com/futurestudio/hapi-rate-limitor#plugin-options) 241 | 242 | ### Updated 243 | - refactoring: move rate limit handling to class 244 | - fix lint issues in test files 245 | - bump dependencies 246 | 247 | ### Deleted 248 | - Travis testing for Node.js v9 249 | 250 | 251 | ## [2.0.1](https://github.com/futurestudio/hapi-rate-limitor/compare/v2.0.0...v2.0.1) - 2018-09-11 252 | 253 | ### Updated 254 | - fix 404 handling: proceed response without rate limit data 255 | 256 | 257 | ## [2.0.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v1.1.1...v2.0.0) - 2018-09-11 258 | 259 | ### Added 260 | - [route-specific rate limits](https://github.com/futurestudio/hapi-rate-limitor#route-options) 261 | - NPM command to calculate coverage 262 | 263 | ### Updated 264 | - fix user-specific rate limits and use the userId as identifier 265 | - switch from `lab` and `code` to `AVA` for testing 266 | 267 | ### Deleted 268 | - unused `.prettierignore` file 269 | 270 | ### Breaking Changes 271 | 272 | - `userLimitKey` becomes `userLimitAttribute` in 2.0: if you used dynamic rate limits with `userLimitKey`, you need to change it to `userLimitAttribute`. 273 | 274 | 275 | ## [1.1.1](https://github.com/futurestudio/hapi-rate-limitor/compare/v1.1.0...v1.1.1) - 2018-08-21 276 | 277 | ### Updated 278 | - Readme: quick navigation and logo size fix for small screens 279 | 280 | 281 | ## [1.1.0](https://github.com/futurestudio/hapi-rate-limitor/compare/v1.0.0...v1.1.0) - 2018-08-08 282 | 283 | ### Added 284 | - [dynamic rate limits](https://github.com/futurestudio/hapi-rate-limitor#dynamic-rate-limits) 285 | - readme describes rate-limit-related response headers 286 | - add logo 287 | 288 | 289 | ## 1.0.0 - 2018-07-11 290 | 291 | ### Added 292 | - `1.0.0` release 🚀 🎉 293 | -------------------------------------------------------------------------------- /lib/rate-limiter.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Redis = require('ioredis') 4 | const Boom = require('@hapi/boom') 5 | const RateLimit = require('./rate-limit') 6 | const Limiter = require('async-ratelimiter') 7 | const RequestIp = require('@supercharge/request-ip') 8 | const RateLimitEventEmitter = require('./event-emitter') 9 | 10 | class RateLimiter extends RateLimitEventEmitter { 11 | /** 12 | * Create a new rate limiter instance. 13 | */ 14 | constructor (server, options = {}) { 15 | const { 16 | view, 17 | getIp, 18 | emitter, 19 | max = 60, 20 | redis = {}, 21 | ipWhitelist = [], 22 | skip = () => false, 23 | duration = 60 * 1000, 24 | userAttribute = 'id', 25 | userLimitAttribute = 'rateLimit', 26 | ...rateLimiterOptions 27 | } = options 28 | 29 | super(emitter, server) 30 | 31 | this.max = max 32 | this.view = view 33 | this.skip = skip 34 | this.getIp = getIp 35 | this.server = server 36 | this.duration = duration 37 | this.userAttribute = userAttribute 38 | 39 | this.externalRedis = false; 40 | 41 | if(redis instanceof Redis) { 42 | this.externalRedis = true; 43 | this.redis = redis; 44 | } else { 45 | this.redis = this.createRedis(redis) 46 | } 47 | 48 | this.ipWhitelist = [].concat(ipWhitelist) 49 | this.userLimitAttribute = userLimitAttribute 50 | this.limiter = this.createLimiter(rateLimiterOptions) 51 | } 52 | 53 | /** 54 | * Create a Redis instance. 55 | * 56 | * @param {Object} config 57 | * 58 | * @returns {Redis} 59 | */ 60 | createRedis (config) { 61 | if (typeof config === 'string') { 62 | return new Redis(config, { lazyConnect: true }) 63 | } 64 | 65 | if (typeof config === 'object') { 66 | return new Redis( 67 | Object.assign(config, { lazyConnect: true }) 68 | ) 69 | } 70 | 71 | throw new Error('Invalid Redis connection details. Valid connection details are a connection string or an ioredis-compatible object.') 72 | } 73 | 74 | /** 75 | * Start the rate limitor and 76 | * connect to Redis. 77 | */ 78 | async start () { 79 | await this.ensureCustomViewExists() 80 | await this.connectRedis() 81 | } 82 | 83 | /** 84 | * Stop the rate limitor and close 85 | * the Redis connection. 86 | */ 87 | async stop () { 88 | await this.disconnectRedis() 89 | } 90 | 91 | /** 92 | * Ensure that the user-defined view 93 | * exists, throw otherwise. 94 | * 95 | * @throws 96 | */ 97 | async ensureCustomViewExists () { 98 | if (this.hasView()) { 99 | try { 100 | await this.server.render(this.view) 101 | } catch (ignoreErr) { 102 | throw new Error(`Cannot find your view file: ${this.view}. Please check the view path.`) 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * Create a Redis connection. 109 | */ 110 | async connectRedis () { 111 | if(!this.externalRedis) await this.redis.connect() 112 | } 113 | 114 | /** 115 | * Close the Redis connection. 116 | */ 117 | async disconnectRedis () { 118 | if(!this.externalRedis) await this.redis.quit() 119 | } 120 | 121 | /** 122 | * Create a new async rate limiter instance from 123 | * the given user options. Defaults to 60 124 | * requests per minute. 125 | * 126 | * @param {Object} options 127 | * 128 | * @returns {Object} 129 | */ 130 | createLimiter (options) { 131 | const config = Object.assign({ 132 | namespace: 'hapi-rate-limitor', 133 | db: this.redis, 134 | duration: this.duration, 135 | max: this.max 136 | }, options) 137 | 138 | return new Limiter(config) 139 | } 140 | 141 | /** 142 | * Rate-limit the incoming request. Proceed the request 143 | * lifecycle if the limit is in quote, create an 144 | * “rate limit exceeded” response otherwise. 145 | * 146 | * @param {Request} request 147 | * @param {Toolkit} h 148 | * 149 | * @returns {Response} 150 | */ 151 | async handle (request, h) { 152 | if (await this.shouldSkip(request)) { 153 | return h.continue 154 | } 155 | 156 | request.rateLimit = await this.rateLimit(request) 157 | 158 | return request.rateLimit.isInQuota() 159 | ? this.proceed(request, h) 160 | : this.handleExceeded(request, h) 161 | } 162 | 163 | /** 164 | * Fire the “in quota” event and continue the request lifecycle. 165 | * 166 | * @param {Request} request 167 | * @param {Toolkit} h 168 | * 169 | * @returns {Symbol} continue symbol 170 | */ 171 | async proceed (request, h) { 172 | await this.fireInQuotaEvent(request) 173 | 174 | return h.continue 175 | } 176 | 177 | /** 178 | * Fire the “exceeded” event and create and error response. 179 | * The response is either a view or a boom error. 180 | * 181 | * @param {Request} request 182 | * @param {Toolkit} h 183 | * 184 | * @returns {*} 185 | */ 186 | async handleExceeded (request, h) { 187 | await this.fireExceededEvent(request) 188 | 189 | if (this.hasView()) { 190 | return h.view(this.view, request.rateLimit).code(429).takeover() 191 | } 192 | 193 | throw Boom.tooManyRequests('You have exceeded the request limit') 194 | } 195 | 196 | /** 197 | * Determines whether to skip rate limiting 198 | * for the given `request`. 199 | * 200 | * @param {Request} request 201 | * 202 | * @returns {Boolean} 203 | */ 204 | async shouldSkip (request) { 205 | return this.isDisabledFor(request) || await this.hasWhitelistedIp(request) || this.skip(request) 206 | } 207 | 208 | /** 209 | * Determines whether the rate limiter 210 | * is disabled for the given request. 211 | * 212 | * @param {Request} request 213 | * 214 | * @returns {Boolean} 215 | */ 216 | isDisabledFor (request) { 217 | return !this.isEnabledFor(request) 218 | } 219 | 220 | /** 221 | * Determines whether the rate limiter 222 | * is enabled for the given request. 223 | * 224 | * @param {Request} request 225 | * 226 | * @returns {Boolean} 227 | */ 228 | isEnabledFor (request) { 229 | const { enabled = true } = this.routeConfig(request) 230 | 231 | return enabled 232 | } 233 | 234 | /** 235 | * Returns the hapi-rate-limitor configuration 236 | * from the requested route route. 237 | * 238 | * @param {Request} request 239 | * 240 | * @returns {Object} 241 | */ 242 | routeConfig (request) { 243 | return request.route.settings.plugins['hapi-rate-limitor'] || {} 244 | } 245 | 246 | /** 247 | * Determine whether the given `request` has a route-level configuration 248 | * for the max number of requests against this endpoint. 249 | * 250 | * @param {Request} request 251 | * 252 | * @returns {Boolean} 253 | */ 254 | hasMaxAttemptsInRouteConfig (request) { 255 | const { max } = this.routeConfig(request) 256 | 257 | return !!max 258 | } 259 | 260 | /** 261 | * Returns `true` if the requesting IP is on the whitelist. 262 | * 263 | * @param {Request} request 264 | * 265 | * @returns {Boolean} 266 | */ 267 | async hasWhitelistedIp (request) { 268 | return this.ipWhitelist.includes( 269 | await this.ip(request) 270 | ) 271 | } 272 | 273 | /** 274 | * Determine the rate limit of the given `request`. Fires 275 | * the “attempt” event before rate-limiting the request. 276 | * 277 | * @param {Request} request 278 | * 279 | * @returns {RateLimit} rate limit instance 280 | */ 281 | async rateLimit (request) { 282 | await this.fireAttemptEvent(request) 283 | 284 | const config = await this.rateLimitConfigFor(request) 285 | 286 | return new RateLimit( 287 | await this.limiter.get(config) 288 | ) 289 | } 290 | 291 | /** 292 | * Returns the configuration used to determine the rate limit for the given `request`. 293 | * 294 | * @param {Request} request 295 | * 296 | * @returns {Object} 297 | */ 298 | async rateLimitConfigFor (request) { 299 | const max = this.resolveMaxAttempts(request) 300 | const routeConfig = this.routeConfig(request) 301 | 302 | return { max, ...routeConfig, id: await this.requestIdentifier(request) } 303 | } 304 | 305 | /** 306 | * Resolve the request identifier used to rate-limit the given `request`. 307 | * 308 | * @param {Request} request 309 | * 310 | * @returns {String} 311 | */ 312 | async requestIdentifier (request) { 313 | return this.hasMaxAttemptsInRouteConfig(request) 314 | ? this.resolveRequestIdentifierForRoute(request) 315 | : this.resolveRequestIdentifier(request) 316 | } 317 | 318 | /** 319 | * Returns the request identifier for the route belonging to the given `request`. 320 | * 321 | * @param {Request} request 322 | * 323 | * @returns {String} 324 | */ 325 | async resolveRequestIdentifierForRoute (request) { 326 | return this.routeIdentifier(request) 327 | .concat(':') 328 | .concat( 329 | await this.resolveRequestIdentifier(request) 330 | ) 331 | } 332 | 333 | /** 334 | * Returns the route-specific identifier for the given `request`. 335 | * The request’s path is used to identify the route. 336 | * 337 | * @param {Request} request 338 | * 339 | * @returns {String} 340 | */ 341 | routeIdentifier (request) { 342 | return String(request.path) 343 | } 344 | 345 | /** 346 | * Resolves the request identifier. Returns the 347 | * user identifier for authenticated requests 348 | * and the IP address otherwise. 349 | * 350 | * @param {Request} request 351 | * 352 | * @returns {String} 353 | */ 354 | async resolveRequestIdentifier (request) { 355 | if (!this.isAuthenticated(request)) { 356 | return this.ip(request) 357 | } 358 | 359 | if (!this.hasUserId(request)) { 360 | return this.ip(request) 361 | } 362 | 363 | return this.userId(request) 364 | } 365 | 366 | /** 367 | * Returns the rate limit if the user is authenticated. 368 | * Unauthenticated requests fall back to the default 369 | * limit of the rate limiter. 370 | * 371 | * @param {Request} request 372 | * 373 | * @returns {Integer} 374 | */ 375 | resolveMaxAttempts (request) { 376 | if (!this.isAuthenticated(request)) { 377 | return this.max 378 | } 379 | 380 | if (!this.hasUserLimit(request)) { 381 | return this.max 382 | } 383 | 384 | return this.userLimit(request) 385 | } 386 | 387 | /** 388 | * Determine whether the request is authenticated. 389 | * 390 | * @param {Request} request 391 | * 392 | * @returns {Boolean} 393 | */ 394 | isAuthenticated (request) { 395 | return !!request.auth.credentials 396 | } 397 | 398 | /** 399 | * Returns true if the authenticated user 400 | * credentials include the property 401 | * to identify the user. 402 | * 403 | * @param {Request} request 404 | * 405 | * @returns {Boolean} 406 | */ 407 | hasUserId (request) { 408 | return !!this.userId(request) 409 | } 410 | 411 | /** 412 | * Returns the user’s unique identifier 413 | * which is used as the rate limit id. 414 | * 415 | * @param {Request} request 416 | * 417 | * @returns {String} 418 | */ 419 | userId (request) { 420 | return request.auth.credentials[this.userAttribute] 421 | } 422 | 423 | /** 424 | * Returns true if the authenticated user 425 | * credentials include the property 426 | * for a user limit. 427 | * 428 | * @param {Request} request 429 | * 430 | * @returns {Boolean} 431 | */ 432 | hasUserLimit (request) { 433 | return !!this.userLimit(request) 434 | } 435 | 436 | /** 437 | * Returns the user’s rate limit. 438 | * 439 | * @param {Request} request 440 | * 441 | * @returns {Integer} 442 | */ 443 | userLimit (request) { 444 | return request.auth.credentials[this.userLimitAttribute] 445 | } 446 | 447 | /** 448 | * Determine whether to render a custom 449 | * “rate limit exceeded” view. 450 | * 451 | * @returns {Boolean} 452 | */ 453 | hasView () { 454 | return !!this.view 455 | } 456 | 457 | /** 458 | * Returns the requesting client’s IP address. 459 | * 460 | * @param {Request} request 461 | * 462 | * @returns {String} 463 | */ 464 | async ip (request) { 465 | return this.getIp 466 | ? this.getIp(request) 467 | : RequestIp.getClientIp(request) 468 | } 469 | 470 | /** 471 | * Extend the response with rate limit headers. 472 | * 473 | * @param {Request} request 474 | * @param {ResponseTookit} h 475 | * 476 | * @returns {Response} 477 | */ 478 | addHeaders (request, h) { 479 | if (request.rateLimit) { 480 | this.assignHeaders(request, h) 481 | } 482 | 483 | return h.continue 484 | } 485 | 486 | /** 487 | * Assign rate limit response headers. 488 | * 489 | * @param {Request} request 490 | * 491 | * @returns {Response} 492 | */ 493 | assignHeaders ({ rateLimit, response }) { 494 | const { total, remaining, reset } = rateLimit 495 | 496 | if (response.isBoom) { 497 | response.output.headers['X-Rate-Limit-Limit'] = total 498 | response.output.headers['X-Rate-Limit-Remaining'] = Math.max(0, remaining) 499 | response.output.headers['X-Rate-Limit-Reset'] = reset 500 | } else { 501 | response 502 | .header('X-Rate-Limit-Limit', total) 503 | .header('X-Rate-Limit-Remaining', Math.max(0, remaining)) 504 | .header('X-Rate-Limit-Reset', reset) 505 | } 506 | 507 | return response 508 | } 509 | } 510 | 511 | module.exports = RateLimiter 512 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | hapi-rate-limitor logo 3 |
4 |
5 | 6 |

7 | Solid and easy to use rate limiting for hapi. 8 |

9 |
10 |

11 | Installation · 12 | Usage · 13 | Plugin Options · 14 | Route Options · 15 | Response Headers 16 |

17 |
18 |
19 |

20 | Build Status 21 | Known Vulnerabilities 22 | Latest Version 23 | Total downloads 24 |

25 |

26 | Follow @marcuspoehls for updates! 27 |

28 |
29 | 30 | ------ 31 | 32 |

The Future Studio University supports development of this hapi plugin 🚀 33 |
34 | Join the Future Studio University and Skyrocket in Node.js 35 |

36 | 37 | ------ 38 | 39 | 40 | ## Introduction 41 | A hapi plugin to prevent brute-force attacks in your app. The rate limiter uses [Redis](https://redis.io/) to store rate-limit related data. 42 | 43 | `hapi-rate-limitor` is built on top of these solid and awesome projects: 44 | 45 | - [async-ratelimiter](https://github.com/microlinkhq/async-ratelimiter) 46 | - [ioredis](https://github.com/luin/ioredis) 47 | - [request-ip](https://github.com/pbojinov/request-ip) 48 | 49 | Each package solves its own problem perfectly. `hapi-rate-limitor` composes the solutions of each problem to a solid rate limit plugin for hapi. 50 | 51 | 52 | ## Requirements 53 | > **hapi v19 (or later)** and **Node.js v12 (or newer)** 54 | 55 | This plugin requires **hapi v19** (or later) and **Node.js v12 or newer**. 56 | 57 | 58 | ### Compatibility 59 | | Major Release | [hapi.js](https://github.com/hapijs/hapi) version | Node.js version | 60 | | --- | --- | --- | 61 | | `v3` | `>=17 hapi` | `>=12` | 62 | | `v2` | `>=17 hapi` | `>=8` | 63 | 64 | 65 | ## Installation 66 | Add `hapi-rate-limitor` as a dependency to your project: 67 | 68 | ```bash 69 | npm i hapi-rate-limitor 70 | ``` 71 | 72 | 73 | ### Using hapi v18 or lower? 74 | Use the `2.x` release line: 75 | 76 | ```bash 77 | npm i hapi-rate-limitor@2 78 | ``` 79 | 80 | 81 | ## Usage 82 | The most straight forward to use `hapi-rate-limitor` is to register it to your hapi server. 83 | 84 | This will use the default configurations of [`async-ratelimiter`](https://github.com/microlinkhq/async-ratelimiter#api) and [ioredis](https://github.com/luin/ioredis/blob/master/API.md). 85 | 86 | ```js 87 | await server.register({ 88 | plugin: require('hapi-rate-limitor') 89 | }) 90 | 91 | // went smooth like chocolate with default settings :) 92 | ``` 93 | 94 | 95 | ## Plugin Options 96 | Customize the plugin’s default configuration with the following options: 97 | 98 | - **`max`**: Integer, default: `60` 99 | - the maximum number of requests allowed in a `duration` 100 | - **`duration`**: Integer, default: `60000` (1 minute) 101 | - the lifetime window keeping records of a request in milliseconds 102 | - **`namespace`**: String, default: `'hapi-rate-limitor'` 103 | - the used prefix to create the rate limit identifier before storing the data 104 | - **`redis`**: Object, default: `undefined` 105 | - the `redis` configuration property will be passed through to `ioredis` creating your custom Redis client 106 | - **`extensionPoint`**: String, default: `'onPostAuth'` 107 | - the [request lifecycle extension point](https://futurestud.io/downloads/hapi/request-lifecycle) for rate limiting 108 | - **`userAttribute`**: String, default: `'id'` 109 | - the property name identifying a user (credentials) for [dynamic rate limits](https://github.com/futurestudio/hapi-rate-limitor#dynamic-rate-limits). This option is used to access the value from `request.auth.credentials`. 110 | - **`userLimitAttribute`**: String, default: `'rateLimit'` 111 | - the property name identifying the rate limit value on [dynamic rate limit](https://github.com/futurestudio/hapi-rate-limitor#dynamic-rate-limits). This option is used to access the value from `request.auth.credentials`. 112 | - **`view`**: String, default: `undefined` 113 | - view path to render the view instead of throwing an error (this uses `h.view(yourView, { total, remaining, reset }).code(429)`) 114 | - **`enabled`**: Boolean, default: `true` 115 | - a shortcut to enable or disable the plugin, e.g. when running tests 116 | - **`skip`**: Function, default: `() => false` 117 | - an async function with the signature `async (request)` to determine whether to skip rate limiting for a given request. The `skip` function retrieves the incoming request as the only argument 118 | - **`ipWhitelist`**: Array, default: `[]` 119 | - an array of whitelisted IP addresses that won’t be rate-limited. Requests from such IPs proceed the request lifecycle. Notice that the related responses won’t contain rate limit headers. 120 | - **`getIp`**: Function, default: `undefined` 121 | - an async function with the signature `async (request)` to manually determine the requesting IP address. This is helpful if your load balancer provides the client IP address as the last item in the list of forwarded addresses (e.g. Heroku and AWS ELB) 122 | - **`emitter`**: Object, default: `server.events` 123 | - an event emitter instance used to emit the [rate-limitting events](https://github.com/futurestudio/hapi-rate-limitor#events) 124 | 125 | All other options are directly passed through to [async-ratelimiter](https://github.com/microlinkhq/async-ratelimiter#api). 126 | 127 | ```js 128 | await server.register({ 129 | plugin: require('hapi-rate-limitor'), 130 | options: { 131 | redis: { 132 | port: 6379, 133 | host: '127.0.0.1' 134 | }, 135 | extensionPoint: 'onPreAuth', 136 | namespace: 'hapi-rate-limitor', 137 | max: 2, // a maximum of 2 requests 138 | duration: 1000, // per second (the value is in milliseconds), 139 | userAttribute: 'id', 140 | userLimitAttribute: 'rateLimit', 141 | view: 'rate-limit-exceeded', // render this view when the rate limit exceeded 142 | enabled: true, 143 | skip: async (request) => { 144 | return request.path.includes('/admin') // example: disable rate limiting for the admin panel 145 | }, 146 | ipWhitelist: ['1.1.1.1'], // list of IP addresses skipping rate limiting 147 | getIp: async (request) => { // manually determine the requesting IP address 148 | const ips = request.headers['x-forwarded-for'].split(',') 149 | 150 | return ips[ips.length - 1] 151 | }, 152 | emitter: yourEventEmitter, // your event emitter instance 153 | } 154 | }) 155 | 156 | // went smooth like chocolate :) 157 | ``` 158 | 159 | You can also use a Redis connection string. 160 | 161 | ```js 162 | await server.register({ 163 | plugin: require('hapi-rate-limitor'), 164 | options: { 165 | redis: 'redis://lolipop:SOME_PASSWORD@dokku-redis-lolipop:6379', 166 | extensionPoint: 'onPreAuth', 167 | namespace: 'hapi-rate-limitor' 168 | // ... etc 169 | } 170 | }) 171 | 172 | // went smooth like chocolate :) 173 | ``` 174 | 175 | Please check the [async-ratelimiter API](https://github.com/microlinkhq/async-ratelimiter#api) for all options. 176 | 177 | 178 | ### Events 179 | `hapi-rate-limitor` dispatches the following three events in the rate-limiting lifecycle: 180 | 181 | - `rate-limit:attempt`: before rate-limiting the request 182 | - `rate-limit:in-quota`: after rate-limiting and only if the request’s limit is in the quota 183 | - `rate-limit:exceeded`: after rate-limiting and only if the request’s quota is exceeded 184 | 185 | Each event listener receives the related request as the only parameter. Here’s a sample listener: 186 | 187 | ```js 188 | 189 | emitter.on('rate-limit:exceeded', request => { 190 | // handle rate-limiting exceeded 191 | }) 192 | ``` 193 | 194 | You can pass your own event `emitter` instance as a config property while registering the `hapi-rate-limitor` plugin to your hapi server. By default, `hapi-rate-limitor` uses hapi’s server as an event emitter. 195 | 196 | ```js 197 | const EventEmitter = require('events') 198 | 199 | const myEmitter = new EventEmitter() 200 | 201 | await server.register({ 202 | plugin: require('hapi-rate-limitor'), 203 | options: { 204 | emitter: myEmitter 205 | 206 | // … other plugin options 207 | } 208 | }) 209 | ``` 210 | 211 | 212 | ## Route Options 213 | Customize the plugin’s default configuration on routes. A use case for this is a login route where you want to reduce the request limit even lower than the default limit. 214 | 215 | On routes, `hapi-rate-limitor` respects all options related to rate limiting. Precisely, all options that [async-ratelimiter](https://github.com/microlinkhq/async-ratelimiter#api) supports. It does not accept Redis connection options or identifiers for dynamic rate limiting. 216 | 217 | All other options are directly passed through to [async-ratelimiter](https://github.com/microlinkhq/async-ratelimiter#api). 218 | 219 | ```js 220 | await server.register({ 221 | plugin: require('hapi-rate-limitor'), 222 | options: { 223 | redis: { 224 | port: 6379, 225 | host: '127.0.0.1' 226 | }, 227 | namespace: 'hapi-rate-limitor', 228 | max: 60, // a maximum of 60 requests 229 | duration: 60 * 1000, // per minute (the value is in milliseconds) 230 | } 231 | }) 232 | 233 | await server.route({ 234 | method: 'POST', 235 | path: '/login', 236 | options: { 237 | handler: () { 238 | // do the login handling 239 | }, 240 | plugins: { 241 | 'hapi-rate-limitor': { 242 | max: 5, // a maximum of 5 requests 243 | duration: 60 * 1000, // per minute 244 | enabled: false // but it’s actually not enabled ;-) 245 | } 246 | } 247 | } 248 | }) 249 | 250 | // went smooth like chocolate :) 251 | ``` 252 | 253 | Please check the [async-ratelimiter API](https://github.com/microlinkhq/async-ratelimiter#api) for all options. 254 | 255 | 256 | ## Dynamic Rate Limits 257 | To make use of user-specific rate limits, you need to configure the `userAttribute` and `userLimitAttribute` attributes in the `hapi-rate-limitor` options. 258 | 259 | These attributes are used to determine the rate limit for an authenticated user. The `userAttribute` is the property name that uniquely identifies a user. The `userLimitAttribute` is the property name that contains the rate limit value. 260 | 261 | ```js 262 | await server.register({ 263 | plugin: require('hapi-rate-limitor'), 264 | options: { 265 | userAttribute: 'id', 266 | userLimitAttribute: 'rateLimit', 267 | max: 500, // a maximum of 500 requests (default is 2500) 268 | duration: 60 * 60 * 1000 // per hour (the value is in milliseconds) 269 | // … other plugin options 270 | } 271 | }) 272 | ``` 273 | 274 | This will calculate the maximum requests individually for each authenticated user based on the user’s `id` and `'rateLimit'` attributes. Imagine the following user object as an authenticated user: 275 | 276 | ```js 277 | /** 278 | * the authenticated user object may contain a custom rate limit attribute. 279 | * In this case, it’s called "rateLimit". 280 | */ 281 | request.auth.credentials = { 282 | id: 'custom-uuid', 283 | rateLimit: 1750, 284 | name: 'Marcus' 285 | // … further attributes 286 | } 287 | ``` 288 | 289 | For this specific user, the maximum amount of requests is `1750` per hour (and not the plugin’s default `500`). 290 | 291 | `hapi-rate-limitor` uses the plugin’s limit if the request is unauthenticated or `request.auth.credentials` doesn’t contain a rate-limit-related attribute. 292 | 293 | 294 | ## Response Headers 295 | The plugin sets the following response headers: 296 | 297 | - `X-Rate-Limit-Limit`: total request limit (`max`) within `duration` 298 | - `X-Rate-Limit-Remaining`: remaining quota until reset 299 | - `X-Rate-Limit-Reset`: time since epoch in seconds that the rate limiting period will end 300 | 301 | 302 | ## Feature Requests 303 | Do you miss a feature? Please don’t hesitate to 304 | [create an issue](https://github.com/futurestudio/hapi-rate-limitor/issues) with a short description of your desired addition to this plugin. 305 | 306 | 307 | ## Links & Resources 308 | 309 | - [hapi tutorial series](https://futurestud.io/tutorials/hapi-get-your-server-up-and-running) with 100+ tutorials 310 | 311 | 312 | ## Contributing 313 | 314 | 1. Create a fork 315 | 2. Create your feature branch: `git checkout -b my-feature` 316 | 3. Commit your changes: `git commit -am 'Add some feature'` 317 | 4. Push to the branch: `git push origin my-new-feature` 318 | 5. Submit a pull request 🚀 319 | 320 | 321 | ## License 322 | 323 | MIT © [Future Studio](https://futurestud.io) 324 | 325 | --- 326 | 327 | > [futurestud.io](https://futurestud.io)  ·  328 | > GitHub [@futurestudio](https://github.com/futurestudio/)  ·  329 | > Twitter [@futurestud_io](https://twitter.com/futurestud_io) 330 | --------------------------------------------------------------------------------