diff --git a/README.md b/README.md index 9fc78e6..45aa565 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ server.register({ event: 'onPostAuth', type: 'users', limitd: limitdClient, - extractKey: function(request, reply, done){ + extractKey: function(request, reply, done) { var key = request.auth.credentials.userId; done(null, key); } @@ -44,11 +44,94 @@ The object has the following schema (validated [here](./lib/index.js) using [Joi * `done: (err: Error, key: String)` - A function that takes an error as the first parameter and the bucket key as the second parameter. **Optional** +* `enabled: Boolean default(true)` - if `true` rate limiting will be enabled you can set it to `false` on route settings to disable rate limiting +* `addHeaders: Boolean default(true)` - if `true` rate limiting headers will be sent with response +* `count: Number default(1)` - how many tokens request should cost useful when you want one route to be more expensive than another but use the same bucket * `onError: (error, reply) => ()` - A function that takes the `error` that occurred when trying to get a token from the bucket and the `reply` interface. * `error: Error` - The error that occurred. * `reply: Reply` - The hapi.js [reply interface](http://hapijs.com/api#reply-interface). > If an error occurs and no function is provided, the request lifecycle continues normally as if there was no token bucket restriction. This is a useful default behavior in case the limitd server goes down. +Options can be overridden in route settings. +if plugin is disabled by default setting patova setting for route will also enable the plugin for the route. + +**Advance Usage** +```javascript +const Hapi = require('hapi'); +const patova = require('patova'); + +const server = new Hapi.Server(); +server.connection({ /* options */ }); + +server.register({register: patova, options: { + event: 'onPostAuth', + type: 'users', + limitd: limitdClient, + extractKey: function(request, reply, done) { + done(null, request.auth.credentials.userId); + }, + enabled: false //rate limiting will not be enabled for all routes + }, +}, err => { + server.route({ + method: 'GET', + path:'/resource', //no rate limiting for get + handler () { /*handler code */} + }); + + server.route({ + method: 'POST', + path:'/resource', + config: { + plugins: { + patova: { //enable rate limiting for this route with default settings + enabled: true + } + } + }, + handler () { /*handler code */} + }); + + server.route({ + method: 'POST', + path:'/expensive_resource', + config: { + plugins: { + patova: { //enable rate limiting and make this route cost more than default one + count: 5 + } + } + }, + handler () { /*handler code */} + }); + + server.route({ + method: 'POST', + path:'/login', + + config: { + auth: { + mode: 'try' + }, + plugins: { + patova: { + type: 'login', //logins are limited on a unique bucket + extractKey: function(request, reply, done) { //we also don't have a user yet so limit access by IP + done(null, request.info.address); + }, + addHeaders: false //lets hide rate limiting information from client for logins + } + } + }, + handler () { /*handler code */} + }); + + +}); +``` + + + ## Contributing Feel free to open issues with questions/bugs/features. PRs are also welcome. diff --git a/lib/index.js b/lib/index.js index 78c28fe..ab3a509 100644 --- a/lib/index.js +++ b/lib/index.js @@ -9,7 +9,10 @@ const schema = Joi.object().keys({ type: [Joi.string(), Joi.func()], limitd: Joi.object(), onError: Joi.func(), - extractKey: Joi.func() + extractKey: Joi.func(), + enabled: Joi.boolean(), + addHeaders: Joi.boolean(), + count: Joi.number() }).requiredKeys('type', 'event', 'limitd', 'extractKey'); function setResponseHeader(request, header, value) { @@ -22,41 +25,25 @@ function setResponseHeader(request, header, value) { } } -function setupPreResponseExt(server, options) { - server.ext('onPreResponse', (request, reply) => { - const requestLimit = request.plugins.patova && request.plugins.patova.limit; - - if (requestLimit && requestLimit.conformant){ - const headers = new RateLimitHeaders( - requestLimit.limit, - requestLimit.remaining, - requestLimit.reset); - - Object.keys(headers).forEach( - key => setResponseHeader(request, key, headers[key])); - } - reply.continue(); - }); -} - function getMinimumLimit(limit1, limit2) { if (!limit1) { return limit2; } if (!limit2) { return limit1; } - if (limit1 && limit2.remaining > limit1.remaining) { + if (limit2.remaining > limit1.remaining) { return limit1; } return limit2; } -function setupRateLimitEventExt(server, options) { - const event = options.event; - const extractKey = options.extractKey; - const onError = options.onError; +function setupRateLimitEventExt(server, pluginOptions) { + const event = pluginOptions.event; + const onError = pluginOptions.onError; - const extractKeyAndTakeToken = function(limitd, request, reply, type) { - extractKey(request, reply, (err, key) =>{ + const extractKeyAndTakeToken = function(options, request, reply, type) { + options.extractKey(request, reply, (err, key) =>{ + const limitd = options.limitd; + const count = options.count; if (err) { return reply(err); } if (!limitd) { @@ -64,15 +51,15 @@ function setupRateLimitEventExt(server, options) { return reply.continue(); } - limitd.take(type, key, (err, currentLimitResponse) => { - if (err){ + limitd.take(type, key, count, (err, currentLimitResponse) => { + if (err) { if (onError) { return onError(err, reply); } // by default we don't fail if limitd is unavailable return reply.continue(); } - const oldMinimumLimitResponse = request.plugins.patova && request.plugins.patova.limit - const newMinimumLimitResponse = getMinimumLimit(currentLimitResponse, oldMinimumLimitResponse) + const oldMinimumLimitResponse = request.plugins.patova && request.plugins.patova.limit; + const newMinimumLimitResponse = getMinimumLimit(currentLimitResponse, oldMinimumLimitResponse); request.plugins.patova = request.plugins.patova || {}; request.plugins.patova.limit = newMinimumLimitResponse; @@ -83,17 +70,19 @@ function setupRateLimitEventExt(server, options) { } const error = Boom.tooManyRequests(); - error.output.headers = new RateLimitHeaders( - newMinimumLimitResponse.limit, - newMinimumLimitResponse.remaining, - newMinimumLimitResponse.reset); + if (options.addHeaders) { + error.output.headers = new RateLimitHeaders( + newMinimumLimitResponse.limit, + newMinimumLimitResponse.remaining, + newMinimumLimitResponse.reset); + } reply(error); }); }); }; - const getType = function(request, reply, callback) { + const getType = function(options, request, reply, callback) { const type = options.type; if (typeof type !== 'function') { @@ -114,18 +103,58 @@ function setupRateLimitEventExt(server, options) { }; server.ext(event, (request, reply) => { + const options = parseRouteOptions(request); + if (!options.enabled) { + return reply.continue(); + } // This handler is going to be called one time per registration of patova - getType(request, reply, (err, type) => { - extractKeyAndTakeToken(options.limitd, request, reply, type); + getType(options, request, reply, (err, type) => { + extractKeyAndTakeToken(options, request, reply, type); }); }); + + server.ext('onPreResponse', (request, reply) => { + const requestLimit = request.plugins.patova && request.plugins.patova.limit; + + if (requestLimit && requestLimit.conformant) { + const options = parseRouteOptions(request); + if (options.addHeaders) { + const headers = new RateLimitHeaders( + requestLimit.limit, + requestLimit.remaining, + requestLimit.reset); + + Object.keys(headers).forEach( + key => setResponseHeader(request, key, headers[key])); + } + } + reply.continue(); + }); + + function parseRouteOptions(request) { + const routeOptions = request.route.settings.plugins.patova; + if (routeOptions === undefined) { + return pluginOptions; + } + if (routeOptions._merged) { //already merged with previous pluginOptions; + return routeOptions; + } + request.route.settings.plugins.patova = Object.assign({_merged: true}, pluginOptions, {enabled: true}, routeOptions); + return request.route.settings.plugins.patova; + } + } -exports.register = function (server, options, next) { - Joi.validate(options, schema, { abortEarly: false }, (err, processedOptions) => { +const defaults = { + enabled: true, + addHeaders: true, + count: 1 +}; + +exports.register = function(server, pluginOptions, next) { + Joi.validate(Object.assign({}, defaults, pluginOptions), schema, { abortEarly: false }, (err, processedOptions) => { if (err) { return next(err); } setupRateLimitEventExt(server, processedOptions); - setupPreResponseExt(server, processedOptions); next(); }); }; diff --git a/test/index.js b/test/index.js index 37bef59..44ff0d7 100644 --- a/test/index.js +++ b/test/index.js @@ -171,7 +171,7 @@ describe('with server', () => { }); }); - describe ('when limitd does not provide a response and there is no onError',function(){ + describe ('when limitd does not provide a response and there is no onError', () => { before(done => { server.start({ replyError: false }, { type: 'user', @@ -400,8 +400,259 @@ function itBehavesLikeWhenLimitdIsRunning(options) { }); }); + describe('when plugin is disabled by default', () => { + before(done => { + server.start({ replyError: false }, { + type: options.emptyType, + limitd: getLimitdClient(address), + extractKey: (request, reply, done) => { done(null, 'notImportant'); }, + event: 'onPostAuth', + onError: (err, reply) => { reply(Boom.wrap(err, 500)); }, + enabled: false + }, done); + }); + + after(server.stop); + + it('should send response with 200 for default routes', () => { + const request = { method: 'POST', url: '/users', payload: { } }; + return PromInject(request, res => { + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('created'); + + }); + }); + + it('should send response with 200 if route explicitly disables patova', () => { + const request = { method: 'POST', url: '/no_limit', payload: { } }; + return PromInject(request, res => { + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('created'); + + }); + }); + + it('should send response with 429 if route has patova settings', () => { + const request = { method: 'POST', url: '/empty', payload: { } }; + return PromInject(request, res => { + const body = JSON.parse(res.payload); + const headers = res.headers; + + expect(res.statusCode).to.equal(429); + expect(body.error).to.equal('Too Many Requests'); + + expect(headers['x-ratelimit-limit']).to.equal(0); + expect(headers['x-ratelimit-remaining']).to.equal(0); + expect(headers['x-ratelimit-reset']).to.equal(0); + + }); + }); + + it('should send response with 429 if route explicitly enables patova', () => { + const request = { method: 'POST', url: '/always_limit', payload: { } }; + return PromInject(request, res => { + const body = JSON.parse(res.payload); + const headers = res.headers; + + expect(res.statusCode).to.equal(429); + expect(body.error).to.equal('Too Many Requests'); + + expect(headers['x-ratelimit-limit']).to.equal(0); + expect(headers['x-ratelimit-remaining']).to.equal(0); + expect(headers['x-ratelimit-reset']).to.equal(0); + + }); + }); + }); + + describe('when routes overrides default type settings', () => { + before(done => { + server.start({ replyError: false }, { + type: options.usersType, + limitd: getLimitdClient(address), + extractKey: (request, reply, done) => { done(null, 'key'); }, + event: 'onPostAuth', + onError: (err, reply) => { reply(Boom.wrap(err, 500)); } + }, done); + }); + + after(server.stop); + + it('should use route type', () => { + const request = { method: 'POST', url: '/empty', payload: { } }; + return PromInject(request, res => { + const body = JSON.parse(res.payload); + const headers = res.headers; + + expect(res.statusCode).to.equal(429); + expect(body.error).to.equal('Too Many Requests'); + + expect(headers['x-ratelimit-limit']).to.equal(0); + expect(headers['x-ratelimit-remaining']).to.equal(0); + expect(headers['x-ratelimit-reset']).to.equal(0); + + }); + }); + }); + + describe('when routes overrides default enable settings', () => { + + before(done => { + server.start({ replyError: false }, { + type: options.emptyType, + limitd: getLimitdClient(address), + extractKey: (request, reply, done) => { done(null, 'key'); }, + event: 'onPostAuth', + onError: (err, reply) => { reply(Boom.wrap(err, 500)); } + }, done); + }); + + after(server.stop); + + it('should not be enabled for disabled routes', () => { + const request = { method: 'POST', url: '/no_limit', payload: { } }; + return PromInject(request, res => { + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('created'); + + }); + }); + }); + + describe('when routes headers are disabled', () => { + + before(done => { + server.start({ replyError: false }, { + type: options.usersType, + limitd: getLimitdClient(address), + extractKey: (request, reply, done) => { done(null, 'key'); }, + event: 'onPostAuth', + onError: (err, reply) => { reply(Boom.wrap(err, 500)); }, + addHeaders: false + }, done); + }); + + after(server.stop); + + it('should not send headers', () => { + const request = { method: 'POST', url: '/empty', payload: { } }; + return PromInject(request, res => { + const headers = res.headers; + + expect(headers).to.not.have.property('x-ratelimit-limit'); + expect(headers).to.not.have.property('x-ratelimit-remaining'); + expect(headers).to.not.have.property('x-ratelimit-reset'); + }); + }); + + it('should send headers when route explicitly enables them', () => { + const request = { method: 'POST', url: '/always_headers', payload: { } }; + return PromInject(request, res => { + const headers = res.headers; + + expect(headers).to.have.property('x-ratelimit-limit'); + expect(headers).to.have.property('x-ratelimit-remaining'); + expect(headers).to.have.property('x-ratelimit-reset'); + }); + }); + + it('should not send headers when route explicitly disables them', () => { + const request = { method: 'POST', url: '/no_headers', payload: { } }; + return PromInject(request, res => { + const headers = res.headers; + + expect(headers).to.not.have.property('x-ratelimit-limit'); + expect(headers).to.not.have.property('x-ratelimit-remaining'); + expect(headers).to.not.have.property('x-ratelimit-reset'); + }); + }); + }); + + describe('when count is more than one', () => { + const total = 10; + const count = 2; + before(done => { + server.start({ replyError: false }, { + type: options.bucket4type, + limitd: getLimitdClient(address), + extractKey: (request, reply, done) => { done(null, 'key'); }, + event: 'onPostAuth', + onError: (err, reply) => { reply(Boom.wrap(err, 500)); }, + count + }, done); + }); + + after(server.stop); + + it('should reduce tokens by default count', () => { + const request = { method: 'POST', url: '/users', payload: { } }; + return PromInject(request, res => { + const headers = res.headers; + expect(res.statusCode).to.equal(200); + + expect(headers['x-ratelimit-limit']).to.equal(total); + expect(headers['x-ratelimit-remaining']).to.equal(total - count); + }); + }); + + it('should reduce tokens by route count', () => { + const request = { method: 'POST', url: '/expensive', payload: { } }; + return PromInject(request, res => { + const headers = res.headers; + expect(res.statusCode).to.equal(200); + + expect(headers['x-ratelimit-limit']).to.equal(total); + expect(headers['x-ratelimit-remaining']).to.equal(total - count - 5); + }); + }); + }); + + describe('when caching route settings', () => { + + before(done => { + server.start({ replyError: false }, { + type: options.usersType, + limitd: getLimitdClient(address), + extractKey: (request, reply, done) => { done(null, 'key'); }, + event: 'onPostAuth', + onError: (err, reply) => { reply(Boom.wrap(err, 500)); } + }, done); + }); + + after(server.stop); + + it('should respect route settings on multiple calls', () => { + const request = { method: 'POST', url: '/empty', payload: { } }; + return PromInject(request, res => { + const body = JSON.parse(res.payload); + const headers = res.headers; + + expect(res.statusCode).to.equal(429); + expect(body.error).to.equal('Too Many Requests'); + + expect(headers['x-ratelimit-limit']).to.equal(0); + expect(headers['x-ratelimit-remaining']).to.equal(0); + expect(headers['x-ratelimit-reset']).to.equal(0); + }).then(() => { + const request = { method: 'POST', url: '/empty', payload: { } }; + return PromInject(request, res => { + const body = JSON.parse(res.payload); + const headers = res.headers; + + expect(res.statusCode).to.equal(429); + expect(body.error).to.equal('Too Many Requests'); + + expect(headers['x-ratelimit-limit']).to.equal(0); + expect(headers['x-ratelimit-remaining']).to.equal(0); + expect(headers['x-ratelimit-reset']).to.equal(0); + }); + }); + }); + }); + describe('when limitd responds conformant', () => { describe('and request response is normal', () => { + before((done) => { server.start({ replyError: false }, { type: options.usersType, @@ -431,6 +682,7 @@ function itBehavesLikeWhenLimitdIsRunning(options) { }); describe('and request response is an error', () => { + before((done) => { server.start({ replyError: true }, { type: options.usersType, @@ -500,6 +752,7 @@ function itBehavesLikeWhenLimitdIsRunning(options) { }); describe('when limitd responds not conformant', () => { + before((done) => { server.start({ replyError: false }, [ { diff --git a/test/server.js b/test/server.js index 5ec5b5e..bae7ce7 100644 --- a/test/server.js +++ b/test/server.js @@ -1,37 +1,119 @@ -var Hapi = require('hapi'); -var plugin = require('../'); -var Boom = require('boom'); +'use strict'; +const Hapi = require('hapi'); +const plugin = require('../'); +const Boom = require('boom'); -var server; +let server; -exports.start = function(replyOptions, pluginOptions, done){ +exports.start = function(replyOptions, pluginOptions, done) { server = new Hapi.Server(); server.connection({ host: 'localhost', - port: 3001, + port: 3001 }); + function handler(request, reply) { + if (replyOptions.replyError) { + reply(Boom.forbidden('You cannot access Zion')); + } else { + reply('created'); + } + } + server.route({ method: 'POST', path:'/users', - handler: function (request, reply) { - if (replyOptions.replyError) { - reply(Boom.forbidden('You cannot access Zion')); - } else { - reply('created'); - } - } + handler }); server.route({ method: 'GET', path:'/forever', - handler: function (request, reply) { + handler: function(request, reply) { setTimeout(() => reply('created'), 1000); } }); + server.route({ + method: 'POST', + path:'/empty', + config: { + plugins: { + patova: { + type: 'empty' + } + } + }, + handler + }); + + server.route({ + method: 'POST', + path:'/no_limit', + config: { + plugins: { + patova: { + enabled: false + } + } + }, + handler + }); + + server.route({ + method: 'POST', + path:'/always_limit', + config: { + plugins: { + patova: { + enabled: true + } + } + }, + handler + }); + + server.route({ + method: 'POST', + path:'/no_headers', + config: { + plugins: { + patova: { + addHeaders: false + } + } + }, + handler + }); + + server.route({ + method: 'POST', + path:'/always_headers', + config: { + plugins: { + patova: { + addHeaders: true + } + } + }, + handler + }); + + server.route({ + method: 'POST', + path:'/expensive', + config: { + plugins: { + patova: { + count: 5 + } + } + }, + handler + }); + + const allPluginOptions = Array.isArray(pluginOptions) ? pluginOptions : [ pluginOptions ]; const plugins = allPluginOptions.map(pluginOptions => this.desc(pluginOptions)); @@ -46,14 +128,14 @@ exports.start = function(replyOptions, pluginOptions, done){ exports.desc = function(pluginOptions) { return { register: plugin, - options: pluginOptions, + options: pluginOptions }; }; -exports.inject = function(){ +exports.inject = function() { server.inject.apply(server, Array.prototype.slice.call(arguments, 0)); }; -exports.stop = function(done){ +exports.stop = function(done) { server.stop(done); };