├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── grape-attack.gemspec ├── lib └── grape │ ├── attack.rb │ └── attack │ ├── adapters │ ├── memory.rb │ └── redis.rb │ ├── configurable.rb │ ├── configuration.rb │ ├── counter.rb │ ├── exceptions.rb │ ├── extension.rb │ ├── limiter.rb │ ├── options.rb │ ├── request.rb │ ├── throttle.rb │ └── version.rb └── spec ├── grape └── attack_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | before_install: gem install bundler -v 1.10.6 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 2 | 3 | * `identifier` proc now gets called in `Grape::Attack::Request` context so that you have access to helper methods 4 | such as `params` and Grape endpoint object. 5 | 6 | # 0.1.1 7 | 8 | * Support X-Real-IP for when behind loadbalancer [https://github.com/gottfrois/grape-attack/pull/3](https://github.com/gottfrois/grape-attack/pull/3) 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in grape-attack.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Pierre-Louis Gottfrois 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code Climate](https://codeclimate.com/github/gottfrois/grape-attack/badges/gpa.svg)](https://codeclimate.com/github/gottfrois/grape-attack) 2 | 3 | # Grape::Attack 4 | 5 | A middleware for Grape to add endpoint-specific throttling. 6 | 7 | ## Why 8 | 9 | You are probably familiar with [Rack::Attack](https://github.com/kickstarter/rack-attack) which does a great job. Grape::Attack was built with simplicity in mind. It was also built to be used directly in [Grape](https://github.com/ruby-grape/grape) APIs without any special configurations. 10 | 11 | It comes with a little DSL that allows you to protect your Grape API endpoints. It also automaticaly sets custom HTTP headers to let your clients know how much requests they have left. 12 | 13 | If you need more advanced feature like black and white listing, you should probably use [Rack::Attack](https://github.com/kickstarter/rack-attack). But if you simply want to do API throttling for each of your Grape endpoints, go ahead and continue reading. 14 | 15 | ## Installation 16 | 17 | Add this line to your application's Gemfile: 18 | 19 | ```ruby 20 | gem 'grape-attack' 21 | ``` 22 | 23 | And then execute: 24 | 25 | $ bundle 26 | 27 | Or install it yourself as: 28 | 29 | $ gem install grape-attack 30 | 31 | ## Usage 32 | 33 | Mount the middleware in your API: 34 | 35 | ```ruby 36 | class MyApi < Grape::API 37 | use Grape::Attack::Throttle 38 | end 39 | ``` 40 | 41 | Define limits per endpoints using `throttle` DSL: 42 | 43 | ```ruby 44 | class MyApi < Grape::API 45 | 46 | use Grape::Attack::Throttle 47 | 48 | resources :comments do 49 | 50 | throttle max: 10, per: 1.minute 51 | get do 52 | Comment.all 53 | end 54 | 55 | end 56 | end 57 | ``` 58 | 59 | Use any [ActiveSupport Time extension to Numeric](http://edgeguides.rubyonrails.org/active_support_core_extensions.html#time) object. 60 | 61 | By default it will use the request ip address to identity the client making the request. 62 | You can pass your own identifier using a `Proc`: 63 | 64 | ```ruby 65 | class MyApi < Grape::API 66 | 67 | use Grape::Attack::Throttle 68 | 69 | helpers do 70 | def current_user 71 | @current_user ||= User.authorize!(env) 72 | end 73 | end 74 | 75 | resources :comments do 76 | 77 | throttle max: 100, per: 1.day, identifier: Proc.new { current_user.id } 78 | get do 79 | Comment.all 80 | end 81 | 82 | end 83 | end 84 | ``` 85 | 86 | When rate limit is reached, it will raise `Grape::Attack::RateLimitExceededError` exception. 87 | You can catch the exception using `rescue_from`: 88 | 89 | ```ruby 90 | class MyApi < Grape::API 91 | 92 | use Grape::Attack::Throttle 93 | 94 | rescue_from Grape::Attack::RateLimitExceededError do |e| 95 | error!({ message: e.message }, 403) 96 | end 97 | 98 | resources :comments do 99 | 100 | throttle max: 100, per: 1.day 101 | get do 102 | Comment.all 103 | end 104 | 105 | end 106 | end 107 | ``` 108 | 109 | Which would result in the following http response: 110 | 111 | ``` 112 | HTTP/1.1 403 Forbidden 113 | Content-Type: application/json 114 | 115 | {"message":"API rate limit exceeded for xxx.xxx.xxx.xxx."} 116 | ``` 117 | 118 | Finally the following headers will automatically be set: 119 | 120 | * `X-RateLimit-Limit` -- The maximum number of requests that the consumer is permitted to make per specified period. 121 | * `X-RateLimit-Remaining` -- The number of requests remaining in the current rate limit window. 122 | * `X-RateLimit-Reset` -- The time at which the current rate limit window resets in [UTC epoch seconds](https://en.wikipedia.org/wiki/Unix_time). 123 | 124 | ## Adapters 125 | 126 | Adapters are used to store the rate counter. 127 | Currently there is only a Redis adapter. You can set redis client url through `env['REDIS_URL']` varialble. 128 | 129 | Defaults to `redis://localhost:6379/0`. 130 | 131 | ## Development 132 | 133 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 134 | 135 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 136 | 137 | ## Contributing 138 | 139 | Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/grape-attack. 140 | 141 | 142 | ## License 143 | 144 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 145 | 146 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "grape/attack" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /grape-attack.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'grape/attack/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "grape-attack" 8 | spec.version = Grape::Attack::VERSION 9 | spec.authors = ["Pierre-Louis Gottfrois"] 10 | spec.email = ["pierrelouis.gottfrois@gmail.com"] 11 | 12 | spec.summary = %q{A middleware for Grape to add endpoint-specific throttling.} 13 | spec.description = %q{A middleware for Grape to add endpoint-specific throttling.} 14 | spec.homepage = "" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "grape", ">= 0.16", "< 2.0" 23 | spec.add_dependency "redis-namespace", "~> 1.5" 24 | spec.add_dependency "activemodel", ">= 4.0" 25 | spec.add_dependency "activesupport", ">= 4.0" 26 | 27 | spec.add_development_dependency "bundler", "~> 1.10" 28 | spec.add_development_dependency "rake", "~> 10.0" 29 | spec.add_development_dependency "rspec" 30 | end 31 | -------------------------------------------------------------------------------- /lib/grape/attack.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/numeric/time.rb' 2 | require 'grape' 3 | 4 | require 'grape/attack/version' 5 | require 'grape/attack/adapters/redis' 6 | require 'grape/attack/adapters/memory' 7 | require 'grape/attack/configurable' 8 | require 'grape/attack/extension' 9 | require 'grape/attack/exceptions' 10 | require 'grape/attack/throttle' 11 | 12 | module Grape 13 | module Attack 14 | extend Configurable 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/grape/attack/adapters/memory.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Attack 3 | module Adapters 4 | class Memory 5 | 6 | attr_reader :data 7 | 8 | def initialize 9 | @data = {} 10 | end 11 | 12 | def get(key) 13 | data[key] 14 | end 15 | 16 | def incr(key) 17 | data[key] ||= 0 18 | data[key] += 1 19 | end 20 | 21 | def expire(key, ttl_in_seconds) 22 | end 23 | 24 | def atomically(&block) 25 | block.call 26 | end 27 | 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/grape/attack/adapters/redis.rb: -------------------------------------------------------------------------------- 1 | require 'redis-namespace' 2 | 3 | module Grape 4 | module Attack 5 | module Adapters 6 | class Redis 7 | 8 | attr_reader :broker 9 | 10 | def initialize 11 | @broker = ::Redis::Namespace.new("grape-attack:#{env}:thottle", redis: ::Redis.new(url: url)) 12 | end 13 | 14 | def get(key) 15 | with_custom_exception do 16 | broker.get(key) 17 | end 18 | end 19 | 20 | def incr(key) 21 | with_custom_exception do 22 | broker.incr(key) 23 | end 24 | end 25 | 26 | def expire(key, ttl_in_seconds) 27 | with_custom_exception do 28 | broker.expire(key, ttl_in_seconds) 29 | end 30 | end 31 | 32 | def atomically(&block) 33 | broker.multi(&block) 34 | end 35 | 36 | private 37 | 38 | def with_custom_exception(&block) 39 | block.call 40 | rescue ::Redis::BaseError => e 41 | raise ::Grape::Attack::StoreError.new(e.message) 42 | end 43 | 44 | def env 45 | if defined?(::Rails) 46 | ::Rails.env 47 | elsif defined?(RACK_ENV) 48 | RACK_ENV 49 | else 50 | ENV['RACK_ENV'] 51 | end 52 | end 53 | 54 | def url 55 | ENV['REDIS_URL'] || 'redis://localhost:6379/0' 56 | end 57 | 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/grape/attack/configurable.rb: -------------------------------------------------------------------------------- 1 | require 'grape/attack/configuration' 2 | 3 | module Grape 4 | module Attack 5 | module Configurable 6 | 7 | def config 8 | @config ||= ::Grape::Attack::Configuration.new 9 | end 10 | 11 | def configure 12 | yield config if block_given? 13 | end 14 | 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/grape/attack/configuration.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Attack 3 | class Configuration 4 | 5 | attr_accessor :adapter, :disable 6 | 7 | def initialize 8 | @adapter = ::Grape::Attack::Adapters::Redis.new 9 | @disable = Proc.new { false } 10 | end 11 | 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/grape/attack/counter.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Attack 3 | class Counter 4 | 5 | attr_reader :request, :adapter 6 | 7 | def initialize(request, adapter) 8 | @request = request 9 | @adapter = adapter 10 | end 11 | 12 | def value 13 | @value ||= begin 14 | adapter.get(key).to_i 15 | rescue ::Grape::Attack::StoreError 16 | 1 17 | end 18 | end 19 | 20 | def update 21 | adapter.atomically do 22 | adapter.incr(key) 23 | adapter.expire(key, ttl_in_seconds) 24 | end 25 | rescue ::Grape::Attack::StoreError 26 | end 27 | 28 | private 29 | 30 | def key 31 | "#{request.method}:#{request.path}:#{request.client_identifier}" 32 | end 33 | 34 | def ttl_in_seconds 35 | request.throttle_options.per.to_i 36 | end 37 | 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/grape/attack/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Attack 3 | StoreError = Class.new(StandardError) 4 | Exceptions = Class.new(StandardError) 5 | RateLimitExceededError = Class.new(Exceptions) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/grape/attack/extension.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Attack 3 | module Extension 4 | 5 | def throttle(options = {}) 6 | route_setting(:throttle, options) 7 | options 8 | end 9 | 10 | ::Grape::API.extend self 11 | 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/grape/attack/limiter.rb: -------------------------------------------------------------------------------- 1 | require 'grape/attack/request' 2 | require 'grape/attack/counter' 3 | 4 | module Grape 5 | module Attack 6 | class Limiter 7 | 8 | attr_reader :request, :adapter, :counter 9 | 10 | def initialize(env, adapter = ::Grape::Attack.config.adapter) 11 | @request = ::Grape::Attack::Request.new(env) 12 | @adapter = adapter 13 | @counter = ::Grape::Attack::Counter.new(@request, @adapter) 14 | end 15 | 16 | def call! 17 | return if disable? 18 | return unless throttle? 19 | 20 | if allowed? 21 | update_counter 22 | set_rate_limit_headers 23 | else 24 | fail ::Grape::Attack::RateLimitExceededError.new("API rate limit exceeded for #{request.client_identifier}.") 25 | end 26 | end 27 | 28 | private 29 | 30 | def disable? 31 | ::Grape::Attack.config.disable.call 32 | end 33 | 34 | def throttle? 35 | request.throttle? 36 | end 37 | 38 | def allowed? 39 | counter.value < max_requests_allowed 40 | end 41 | 42 | def update_counter 43 | counter.update 44 | end 45 | 46 | def set_rate_limit_headers 47 | request.context.route_setting(:throttle)[:remaining] = [0, max_requests_allowed - (counter.value + 1)].max 48 | end 49 | 50 | def max_requests_allowed 51 | request.throttle_options.max.to_i 52 | end 53 | 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/grape/attack/options.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | 3 | module Grape 4 | module Attack 5 | class Options 6 | include ActiveModel::Model 7 | include ActiveModel::Validations 8 | 9 | attr_accessor :max, :per, :identifier, :remaining 10 | 11 | class ProcOrNumberValidator < ActiveModel::EachValidator 12 | def validate_each(record, attribute, value) 13 | return true if value.is_a?(Numeric) 14 | return true if value.is_a?(Proc) && value.call.is_a?(Numeric) 15 | 16 | record.errors.add attribute, "must be either a proc resolving in a numeric or a numeric" 17 | end 18 | end 19 | 20 | validates :max, proc_or_number: true 21 | validates :per, proc_or_number: true 22 | 23 | def identifier 24 | @identifier || Proc.new {} 25 | end 26 | 27 | def max 28 | return @max if @max.is_a?(Numeric) 29 | return @max.call if @max.is_a?(Proc) 30 | super 31 | end 32 | 33 | def per 34 | return @per if @per.is_a?(Numeric) 35 | return @per.call if @per.is_a?(Proc) 36 | super 37 | end 38 | 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/grape/attack/request.rb: -------------------------------------------------------------------------------- 1 | require 'grape/attack/options' 2 | 3 | module Grape 4 | module Attack 5 | class Request 6 | 7 | attr_reader :env, :context, :request, :throttle_options 8 | 9 | def initialize(env) 10 | @env = env 11 | @context = env['api.endpoint'] 12 | @request = @context.routes.first 13 | @throttle_options = ::Grape::Attack::Options.new(@context.route_setting(:throttle)) 14 | end 15 | 16 | def method 17 | request.request_method 18 | end 19 | 20 | def path 21 | request.path 22 | end 23 | 24 | def params 25 | request.params 26 | end 27 | 28 | def method_missing(method_name, *args, &block) 29 | context.public_send(method_name, *args, &block) 30 | end 31 | 32 | def respond_to_missing?(method_name, include_private = false) 33 | context.respond_to?(method_name) 34 | end 35 | 36 | def client_identifier 37 | self.instance_eval(&throttle_options.identifier) || env['HTTP_X_REAL_IP'] || env['REMOTE_ADDR'] 38 | end 39 | 40 | def throttle? 41 | return false unless context.route_setting(:throttle).present? 42 | return true if throttle_options.valid? 43 | 44 | fail ArgumentError.new(throttle_options.errors.full_messages) 45 | end 46 | 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/grape/attack/throttle.rb: -------------------------------------------------------------------------------- 1 | require 'grape/attack/limiter' 2 | 3 | module Grape 4 | module Attack 5 | class Throttle < Grape::Middleware::Base 6 | 7 | def before 8 | ::Grape::Attack::Limiter.new(env).call! 9 | end 10 | 11 | def after 12 | request = ::Grape::Attack::Request.new(env) 13 | 14 | return if ::Grape::Attack.config.disable.call 15 | return unless request.throttle? 16 | 17 | header('X-RateLimit-Limit', request.throttle_options.max.to_s) 18 | header('X-RateLimit-Reset', request.throttle_options.per.to_s) 19 | header('X-RateLimit-Remaining', request.throttle_options.remaining.to_s) 20 | 21 | @app_response 22 | end 23 | 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/grape/attack/version.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Attack 3 | VERSION = '0.3.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/grape/attack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Grape::Attack do 4 | it 'has a version number' do 5 | expect(Grape::Attack::VERSION).not_to be nil 6 | end 7 | 8 | it 'does something useful' do 9 | expect(false).to eq(true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'grape/attack' 3 | --------------------------------------------------------------------------------