├── 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 |
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 |
--------------------------------------------------------------------------------