├── .ruby-version ├── .ruby-gemset ├── Gemfile ├── lib └── rack │ ├── defense │ ├── version.rb │ └── throttle_counter.rb │ └── defense.rb ├── Rakefile ├── Gemfile.lock ├── spec ├── spec_helper.rb ├── defense_config_spec.rb ├── defense_throttle_expire_keys_spec.rb ├── defense_ban_spec.rb ├── defense_callbacks_spec.rb ├── throttle_counter_spec.rb └── defense_throttle_spec.rb ├── rack-defense.gemspec ├── LICENSE ├── CHANGELOG.md ├── .gitignore └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.5.1 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | rack-defense 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /lib/rack/defense/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Defense 3 | VERSION = '0.2.6' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | require 'bundler/gem_tasks' 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.pattern = 'spec/*_spec.rb' 6 | end 7 | 8 | task default: :test 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rack-defense (0.2.6) 5 | rack (>= 1.6.11) 6 | redis (~> 4) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | minitest (5.11.3) 12 | rack (2.0.6) 13 | rack-test (1.1.0) 14 | rack (>= 1.0, < 3) 15 | rake (10.5.0) 16 | redis (4.0.3) 17 | timecop (0.9.1) 18 | 19 | PLATFORMS 20 | ruby 21 | 22 | DEPENDENCIES 23 | minitest (~> 5.4) 24 | rack-defense! 25 | rack-test (>= 1.0.0) 26 | rake (~> 10.3) 27 | timecop (~> 0.7) 28 | 29 | BUNDLED WITH 30 | 1.17.1 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'rack/test' 3 | require 'redis' 4 | require 'timecop' 5 | require 'rack/defense' 6 | 7 | class MiniTest::Spec 8 | include Rack::Test::Methods 9 | 10 | def status_ok; 200 end 11 | def status_throttled; 429 end 12 | def status_banned; 403 end 13 | 14 | def app 15 | Rack::Builder.new { 16 | use Rack::Defense 17 | run ->(_) { [200, {}, ['Hello World']] } 18 | }.to_app 19 | end 20 | 21 | before do 22 | Timecop.safe_mode = true 23 | keys = Redis.current.keys("#{Rack::Defense::ThrottleCounter::KEY_PREFIX}:*") 24 | Redis.current.del *keys if keys.any? 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/defense_config_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe Rack::Defense::Config do 4 | before do 5 | @config = Rack::Defense::Config.new 6 | end 7 | 8 | describe 'store' do 9 | it 'creates store instance from connection string' do 10 | url = 'redis://localhost:4444' 11 | @config.store = url 12 | assert url, conn_url(@config.store) 13 | end 14 | 15 | it 'update proxied store instance when store config changes' do 16 | obj1, obj2 = Redis.new(url: 'redis://localhost:3333'), Redis.new(url: 'redis://localhost:4444') 17 | 18 | @config.store = obj1 19 | assert conn_url(obj1), conn_url(@config.store) 20 | 21 | cached_store = @config.store 22 | 23 | @config.store = obj2 24 | assert conn_url(obj2), conn_url(@config.store) 25 | assert conn_url(obj2), conn_url(cached_store) 26 | end 27 | 28 | def conn_url(conn) 29 | "redis://#{conn.connection[:host]}:#{conn.connection[:port]}" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /rack-defense.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib/', __FILE__) 3 | $:.unshift lib unless $:.include?(lib) 4 | 5 | require 'rack/defense/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'rack-defense' 9 | s.version = Rack::Defense::VERSION 10 | s.license = 'MIT' 11 | 12 | s.authors = ['Chaker Nakhli'] 13 | s.email = ['chaker.nakhli@gmail.com'] 14 | 15 | s.files = Dir.glob('{bin,lib}/**/*') + %w(Rakefile README.md) 16 | s.test_files = Dir.glob('spec/**/*') 17 | s.homepage = 'http://github.com/nakhli/rack-defense' 18 | s.rdoc_options = ['--charset=UTF-8'] 19 | s.require_paths = ['lib'] 20 | s.summary = 'Throttle and filter requests' 21 | s.description = 'A rack middleware for throttling and filtering requests' 22 | 23 | s.required_ruby_version = '>= 1.9.2' 24 | 25 | s.add_dependency 'rack', '>= 1.6.11' 26 | s.add_dependency 'redis', '~> 4' 27 | s.add_development_dependency 'rake', '~> 10.3' 28 | s.add_development_dependency 'minitest', '~> 5.4' 29 | s.add_development_dependency 'rack-test', '>= 1.0.0' 30 | s.add_development_dependency 'timecop', '~> 0.7' 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Sinbadsoft, Chaker NAKHLI 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. -------------------------------------------------------------------------------- /spec/defense_throttle_expire_keys_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe 'Rack::Defense::throttle_expire_keys' do 4 | def window 5 | 10 * 1000 # in milliseconds 6 | end 7 | 8 | before do 9 | Rack::Defense.setup do |config| 10 | # allow 1 requests per #window per ip 11 | config.throttle('rule', 3, window) { |req| req.ip if req.path == '/path' } 12 | end 13 | end 14 | 15 | it 'expire throttle key' do 16 | ip = '192.168.169.244' 17 | throttle_key = "#{Rack::Defense::ThrottleCounter::KEY_PREFIX}:rule:#{ip}" 18 | redis = Rack::Defense.config.store 19 | start = Time.now.to_i 20 | 3.times do 21 | get '/path', {}, 'REMOTE_ADDR' => ip 22 | assert status_ok, last_response.status 23 | end 24 | 25 | get '/path', {}, 'REMOTE_ADDR' => ip 26 | elapsed = Time.now.to_i - start 27 | if elapsed < window 28 | assert status_throttled, last_response.status 29 | assert redis.exists throttle_key 30 | else 31 | puts "Warning: test too slow elapsed:#{elapsed}s expected < #{window}" 32 | end 33 | 34 | # Since Redis 2.6 the expire error is from 0 to 1 milliseconds. See http://redis.io/commands/expire 35 | sleep (window / 1000) + 0.002 36 | 37 | refute redis.exists throttle_key 38 | end 39 | end -------------------------------------------------------------------------------- /spec/defense_ban_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | describe 'Rack::Defense::ban' do 3 | before do 4 | # 5 | # configure the Rack::Defense middleware with a ban 6 | # strategy. 7 | # 8 | Rack::Defense.setup do |config| 9 | # allow only given ips on path 10 | config.ban('allow_only_ip_list') do |req| 11 | req.path == '/protected' && !%w(192.168.0.1 127.0.0.1).include?(req.ip) 12 | end 13 | end 14 | end 15 | it 'ban matching requests' do 16 | check_request(:get, '/protected','192.168.0.2') 17 | check_request(:post, '/protected','192.168.0.3') 18 | check_request(:patch, '/protected','192.168.0.2') 19 | check_request(:delete, '/protected','192.168.0.2') 20 | end 21 | it 'allow non matching request' do 22 | check_request(:get, '/protected','192.168.0.1') 23 | check_request(:get, '/protected','127.0.0.1') 24 | check_request(:get, '/protectedx','192.168.0.5') 25 | check_request(:post, '/allowed','192.168.0.5') 26 | end 27 | 28 | def check_request(verb, path, ip) 29 | send verb, path, {}, 'REMOTE_ADDR' => ip 30 | expected_status = path == '/protected' && !%w(192.168.0.1 127.0.0.1).include?(ip) ? 31 | status_banned : status_ok 32 | assert_equal expected_status, last_response.status 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /lib/rack/defense/throttle_counter.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Defense 3 | class ThrottleCounter 4 | KEY_PREFIX = 'rack-defense' 5 | 6 | attr_accessor :name 7 | 8 | def initialize(name, max_requests, time_period, store) 9 | @name, @max_requests, @time_period = name.to_s, max_requests.to_i, time_period.to_i 10 | raise ArgumentError, 'name should not be nil or empty' if @name.empty? 11 | raise ArgumentError, 'max_requests should be greater than zero' unless @max_requests > 0 12 | raise ArgumentError, 'time_period should be greater than zero' unless @time_period > 0 13 | @store = store 14 | end 15 | 16 | def throttle?(key, timestamp=nil) 17 | timestamp ||= (Time.now.utc.to_f * 1000).to_i 18 | @store.eval SCRIPT, 19 | ["#{KEY_PREFIX}:#{@name}:#{key}"], 20 | [timestamp, @max_requests, @time_period] 21 | end 22 | 23 | SCRIPT = <<-LUA_SCRIPT 24 | local key = KEYS[1] 25 | local timestamp, max_requests, time_period = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]) 26 | local throttle = (redis.call('rpush', key, timestamp) > max_requests) and 27 | (timestamp - time_period) <= tonumber(redis.call('lpop', key)) 28 | redis.call('pexpire', key, time_period) 29 | return throttle 30 | LUA_SCRIPT 31 | 32 | private_constant :SCRIPT 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.6 2 | tag 0.2.6 3 | 4 | Update rack version due to vulnerability 5 | Update redis version 6 | 7 | # 0.2.5 8 | tag 0.2.5 9 | 10 | Update dependencies. 11 | 12 | # 0.2.4 13 | tag 0.2.4 14 | 15 | Update dependencies and author info in gemspec. 16 | 17 | # 0.2.3 18 | tag 0.2.3 19 | 20 | Update dependencies. 21 | 22 | # 0.2.2 23 | tag 0.2.2 24 | 25 | Update dependencies. 26 | 27 | # 0.2.1 28 | tag 0.2.1 29 | 30 | * Added auto-expiration of redis throttle keys using the redis command [PEXPIRE](http://redis.io/commands/pexpire). 31 | A throttle key is automatically cleaned up by redis if no activity is recorded against this key for more 32 | than the specified throttle period. 33 | 34 | # 0.2.0 35 | tag 0.2.0 36 | 37 | * Added notifications for throttle and ban events. Callbacks are registered with `Config#after_ban` and 38 | `Config#after_throttle` methods. 39 | 40 | # 0.1.1 41 | tag 0.1.1 42 | 43 | * Relax rack and redis gem required versions 44 | * Wrap redis connection with a proxy before initializing `ThrottleCounter` instances. 45 | This avoids having to do the store initialization (`Config#sotre=`) -if any- before declaring 46 | throttle rules (`Config#throttle`). For instance, the following configuration is now correct: 47 | 48 | ```ruby 49 | Rack::Defense.setup do |config| 50 | config.throttle('name', 100, 1000) { |req| req.ip if req.path='/path' } 51 | 52 | # no need to set the store before the throttle rule. it can be done at any moment in config section 53 | config.store = 'redis://server:3333/0' 54 | end 55 | ``` 56 | 57 | # 0.1.0 58 | tag 0.1.0 59 | 60 | * Throttle requests using a sliding window with period/max_request and request criteria. 61 | * Ban (block) requests matching criteria. 62 | * `Rack::Defense#setup` to configure redis store and throttled and banned responses 63 | -------------------------------------------------------------------------------- /spec/defense_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe 'Rack::Defense::callbacks' do 4 | before do 5 | @start_time = Time.utc(2015, 10, 30, 21, 0, 0) 6 | @throttled = [] 7 | @banned = [] 8 | 9 | Rack::Defense.setup do |config| 10 | config.throttle('login', 3, 10 * 1000) do |req| 11 | req.ip if req.path == '/login' && req.post? 12 | end 13 | 14 | config.ban('forbidden') do |req| 15 | req.path == '/forbidden' 16 | end 17 | 18 | # get notified when requests get throttled 19 | config.after_throttle do |req, rules| 20 | @throttled << [req, rules] 21 | end 22 | 23 | # get notified when requests get banned 24 | config.after_ban do |req, rule| 25 | @banned << [req, rule] 26 | end 27 | end 28 | end 29 | it 'throttle rule gets called' do 30 | 5.times do |offset| 31 | time = @start_time + offset 32 | Timecop.freeze(time) do 33 | post '/login', {}, 'REMOTE_ADDR' => '192.168.0.1' 34 | if offset < 3 35 | assert_equal status_ok, last_response.status 36 | assert_equal 0, @throttled.length 37 | else 38 | assert_equal status_throttled, last_response.status 39 | check_callback_data(@throttled, offset - 2, { 'login' => '192.168.0.1' }, '/login') 40 | end 41 | end 42 | end 43 | end 44 | it 'ban callback gets called' do 45 | 5.times do |i| 46 | get '/forbidden' 47 | assert_equal status_banned, last_response.status 48 | check_callback_data(@banned, i + 1, 'forbidden', '/forbidden') 49 | end 50 | end 51 | 52 | def check_callback_data(trace, matching_request_count, rule_data, req_path) 53 | assert_equal matching_request_count, trace.length 54 | data = trace[-1] 55 | # check callback data 56 | assert_equal req_path, data[0].path 57 | assert_equal rule_data, data[1] 58 | end 59 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------------- 2 | # Ignore these files when commiting to a git repository. 3 | # 4 | # See http://help.github.com/ignore-files/ for more about ignoring files. 5 | # 6 | # The original version of this file is found here: 7 | # https://github.com/RailsApps/rails-composer/blob/master/files/gitignore.txt 8 | # 9 | # Corrections? Improvements? Create a GitHub issue: 10 | # http://github.com/RailsApps/rails-composer/issues 11 | #---------------------------------------------------------------------------- 12 | 13 | # bundler state 14 | /.bundle 15 | /vendor/bundle/ 16 | /vendor/ruby/ 17 | 18 | # minimal Rails specific artifacts 19 | db/*.sqlite3 20 | /db/*.sqlite3-journal 21 | /log/* 22 | /tmp/* 23 | 24 | # various artifacts 25 | **.war 26 | *.rbc 27 | *.sassc 28 | .rspec 29 | .redcar/ 30 | .sass-cache 31 | /config/config.yml 32 | /config/database.yml 33 | /coverage.data 34 | /coverage/ 35 | /db/*.javadb/ 36 | /db/*.sqlite3 37 | /doc/api/ 38 | /doc/app/ 39 | /doc/features.html 40 | /doc/specs.html 41 | /public/cache 42 | /public/stylesheets/compiled 43 | /public/assets/ 44 | /public/system/* 45 | /spec/tmp/* 46 | /cache 47 | /capybara* 48 | /capybara-*.html 49 | /gems 50 | /specifications 51 | rerun.txt 52 | pickle-email-*.html 53 | .zeus.sock 54 | /pkg 55 | 56 | # If you find yourself ignoring temporary files generated by your text editor 57 | # or operating system, you probably want to add a global ignore instead: 58 | # git config --global core.excludesfile ~/.gitignore_global 59 | # 60 | # Here are some files you may want to ignore globally: 61 | 62 | # scm revert files 63 | **.orig 64 | 65 | # Mac finder artifacts 66 | .DS_Store 67 | 68 | # Netbeans project directory 69 | /nbproject/ 70 | 71 | # RubyMine project files 72 | .idea 73 | 74 | # Textmate project files 75 | /*.tmproj 76 | 77 | # vim artifacts 78 | **.swp 79 | 80 | # Environment files that may contain sensitive data 81 | .env 82 | .env.* 83 | .powenv 84 | -------------------------------------------------------------------------------- /spec/throttle_counter_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe Rack::Defense::ThrottleCounter do 4 | before do 5 | @key = '192.168.0.1' 6 | end 7 | describe '.throttle?' do 8 | window = 60 * 1000 9 | before { @counter = Rack::Defense::ThrottleCounter.new('upload_photo', 5, window, Redis.current) } 10 | it 'allow request number max_requests if after period' do 11 | do_max_requests_minus_one 12 | refute @counter.throttle? @key, window + 1 13 | end 14 | it 'block request number max_requests if in period' do 15 | do_max_requests_minus_one 16 | assert @counter.throttle? @key, window 17 | end 18 | it 'allow consecutive valid periods' do 19 | (0..20).each { |i| do_max_requests_minus_one((window + 1) * i) } 20 | end 21 | it 'block consecutive invalid requests' do 22 | do_max_requests_minus_one 23 | (0..20).each { |i| assert @counter.throttle?(@key, window + i) } 24 | end 25 | it 'use a sliding window and not reset count after each full period' do 26 | [5, 4, 3, 2, 1].map { |e| window - e }.each { |t| refute @counter.throttle?(@key, t), "timestamp #{t}" } 27 | [1, 2, 3, 4].map { |e| window + e }.each { |t| assert @counter.throttle?(@key, t), "timestamp #{t}"} 28 | end 29 | it 'should unblock after blocking requests' do 30 | do_max_requests_minus_one 31 | assert @counter.throttle? @key, window 32 | assert @counter.throttle? @key, window + 1 33 | refute @counter.throttle? @key, window + 6 34 | end 35 | it 'should include throttled(blocked) request into the request count' do 36 | [0, 1, 2, 3, 4].each { |t| refute @counter.throttle?(@key, t), "timestamp #{t}" } 37 | assert @counter.throttle? @key, window 38 | [16, 17, 18, 19].map { |e| window + e }.each { |t| refute @counter.throttle?(@key, t), "timestamp #{t}" } 39 | assert @counter.throttle? @key, window + 20 40 | end 41 | end 42 | describe 'expire keys' do 43 | before do 44 | @redis = Redis.current 45 | @counter = Rack::Defense::ThrottleCounter.new('rule_name', 3, 10 * 1000, @redis) 46 | @throttle_key = "#{Rack::Defense::ThrottleCounter::KEY_PREFIX}:rule_name:#{@key}" 47 | end 48 | it 'expire throttle key' do 49 | start = Time.now.to_i 50 | 51 | 3.times do 52 | refute @counter.throttle? @key 53 | end 54 | 55 | elapsed = Time.now.to_i - start 56 | if elapsed < 10 57 | assert @counter.throttle? @key 58 | assert @redis.exists @throttle_key 59 | else 60 | puts "Warning: test too slow elapsed:#{elapsed}s expected < #{10}" 61 | end 62 | 63 | # Since Redis 2.6 the expire error is from 0 to 1 milliseconds. See http://redis.io/commands/expire 64 | sleep 10 + 0.002 65 | 66 | refute @redis.exists @throttle_key 67 | end 68 | end 69 | 70 | def do_max_requests_minus_one(offset=0) 71 | [0, 2, 3, 5, 9].map { |t| t + offset }.each do |t| 72 | refute @counter.throttle?(@key, t), "timestamp #{t}" 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/rack/defense.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'redis' 3 | require 'delegate' 4 | 5 | class Rack::Defense 6 | autoload :ThrottleCounter, 'rack/defense/throttle_counter' 7 | 8 | class Config 9 | BANNED_RESPONSE = ->(_) { [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] } 10 | THROTTLED_RESPONSE = ->(_) { [429, {'Content-Type' => 'text/plain'}, ["Retry later\n"]] } 11 | 12 | attr_accessor :banned_response 13 | attr_accessor :throttled_response 14 | 15 | attr_reader :bans 16 | attr_reader :throttles 17 | attr_reader :ban_callbacks 18 | attr_reader :throttle_callbacks 19 | 20 | def initialize 21 | self.banned_response = BANNED_RESPONSE 22 | self.throttled_response = THROTTLED_RESPONSE 23 | @throttles, @bans = {}, {} 24 | @ban_callbacks, @throttle_callbacks = [], [] 25 | end 26 | 27 | def throttle(rule_name, max_requests, period, &block) 28 | raise ArgumentError, 'rule name should not be nil' unless rule_name 29 | counter = ThrottleCounter.new(rule_name, max_requests, period, store) 30 | throttles[rule_name] = lambda do |req| 31 | key = block.call(req) 32 | key if key && counter.throttle?(key) 33 | end 34 | end 35 | 36 | def ban(rule_name, &block) 37 | raise ArgumentError, 'rule name should not be nil' unless rule_name 38 | bans[rule_name] = block 39 | end 40 | 41 | def after_ban(&block) 42 | ban_callbacks << block 43 | end 44 | 45 | def after_throttle(&block) 46 | throttle_callbacks << block 47 | end 48 | 49 | def store=(value) 50 | value = Redis.new(url: value) if value.is_a?(String) 51 | if @store 52 | @store.__setobj__(value) 53 | else 54 | @store = SimpleDelegator.new(value) 55 | end 56 | end 57 | 58 | def store 59 | # Redis.new uses REDIS_URL environment variable by default as URL. 60 | # See https://github.com/redis/redis-rb 61 | @store ||= SimpleDelegator.new(Redis.new) 62 | end 63 | end 64 | 65 | class << self 66 | attr_accessor :config 67 | 68 | def setup 69 | self.config = Config.new 70 | yield config 71 | end 72 | 73 | def ban?(req) 74 | entry = config.bans.find { |_, filter| filter.call(req) } 75 | matching_rule = entry[0] if entry 76 | yield config.ban_callbacks, req, matching_rule if matching_rule && block_given? 77 | matching_rule 78 | end 79 | 80 | def throttle?(req) 81 | matching_rules = config.throttles. 82 | map { |rule_name, filter| [rule_name, filter.call(req)] }. 83 | select { |e| e[1] }. 84 | to_h 85 | yield config.throttle_callbacks, req, matching_rules if matching_rules.any? && block_given? 86 | matching_rules if matching_rules.any? 87 | end 88 | end 89 | 90 | def initialize(app) 91 | @app = app 92 | end 93 | 94 | def call(env) 95 | klass, config = self.class, self.class.config 96 | req = ::Rack::Request.new(env) 97 | 98 | if klass.ban?(req, &method(:invoke_callbacks)) 99 | config.banned_response.call(env) 100 | elsif klass.throttle?(req, &method(:invoke_callbacks)) 101 | config.throttled_response.call(env) 102 | else 103 | @app.call(env) 104 | end 105 | end 106 | 107 | private 108 | 109 | def invoke_callbacks(callbacks, req, rule_data) 110 | callbacks.each do |callback| 111 | begin 112 | callback.call(req, rule_data) 113 | rescue 114 | # mute exception 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/defense_throttle_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe 'Rack::Defense::throttle' do 4 | def window 5 | 60 * 1000 # in milliseconds 6 | end 7 | 8 | before do 9 | @start_time = Time.utc(2015, 10, 30, 21, 0, 0) 10 | 11 | # 12 | # configure the Rack::Defense middleware with throttling 13 | # strategies. 14 | # 15 | Rack::Defense.setup do |config| 16 | # allow only 3 post requests on path '/login' per #window per ip 17 | config.throttle('login', 3, window) do |req| 18 | req.ip if req.path == '/login' && req.post? 19 | end 20 | 21 | # allow only 30 get requests on path '/search' per #window per ip 22 | config.throttle('res', 30, window) do |req| 23 | req.ip if req.path == '/search' && req.get? 24 | end 25 | 26 | # allow only 5 get requests on path /api/* per #window per authorization token 27 | config.throttle('api', 5, window) do |req| 28 | req.env['HTTP_AUTHORIZATION'] if %r{^/api/} =~ req.path 29 | end 30 | end 31 | end 32 | it 'allow ok post' do 33 | check_post_request 34 | end 35 | it 'allow ok get' do 36 | check_get_request 37 | end 38 | it 'ban get requests higher than acceptable rate' do 39 | 10.times do |period| 40 | 50.times { |offset| check_get_request(offset + period*window) } 41 | end 42 | end 43 | it 'ban post requests higher than acceptable rate' do 44 | 10.times do |period| 45 | 7.times { |offset| check_post_request(offset + period*window) } 46 | end 47 | end 48 | it 'not have side effects between different throttle rules with mixed requests' do 49 | 10.times do |period| 50 | 50.times do |offset| 51 | check_get_request(offset + period*window) 52 | check_post_request(offset + period*window) 53 | end 54 | end 55 | end 56 | it 'not have side effects between request filtered by the same rule but with different keys' do 57 | 10.times do |period| 58 | 50.times do |offset| 59 | check_get_request(offset + period*window, ip='192.168.0.1') 60 | check_get_request(offset + period*window, ip='192.168.0.2') 61 | end 62 | end 63 | end 64 | it 'allow unfiltered requests' do 65 | 50.times do |offset| 66 | time = @start_time + offset 67 | Timecop.freeze(time) do 68 | # the rule matches the '/search' path and not '/searchx' 69 | get '/searchx', {}, 'REMOTE_ADDR' => '192.168.0.1' 70 | assert_equal status_ok, last_response.status 71 | 72 | # the rule matches only get requests and not post 73 | post '/search', {}, 'REMOTE_ADDR' => '192.168.0.1' 74 | assert_equal status_ok, last_response.status 75 | end 76 | end 77 | 10.times do |offset| 78 | time = @start_time + offset 79 | Timecop.freeze(time) do 80 | # the rule matches only post and not get 81 | get '/login', {}, 'REMOTE_ADDR' => '192.168.0.1' 82 | assert_equal status_ok, last_response.status 83 | end 84 | end 85 | end 86 | it 'not have side effects between unfiltered and filtered requests' do 87 | 50.times do |offset| 88 | time = @start_time + offset 89 | Timecop.freeze(time) do 90 | get '/searchx', {}, 'REMOTE_ADDR' => '192.168.0.1' 91 | assert_equal status_ok, last_response.status 92 | get '/search', {}, 'REMOTE_ADDR' => '192.168.0.1' 93 | assert_equal offset < 30 ? status_ok : status_throttled, last_response.status 94 | end 95 | end 96 | end 97 | it 'should work with key in http header' do 98 | 10.times do |offset| 99 | check_request(:get, '/api/action', offset, 5, 100 | '192.168.0.1', 101 | 'HTTP_AUTHORIZATION' => 'token api_token_here') 102 | end 103 | end 104 | 105 | def check_get_request(time_offset=0, ip='192.168.0.1', path='/search') 106 | check_request(:get, path, time_offset, 30, ip) 107 | end 108 | 109 | def check_post_request(time_offset=0, ip='192.168.0.1', path='/login') 110 | check_request(:post, path, time_offset, 3, ip) 111 | end 112 | 113 | def check_request(verb, path, time_offset, max_requests, ip, headers={}) 114 | Timecop.freeze(@start_time + time_offset) do 115 | send verb, path, {}, headers.merge('REMOTE_ADDR' => ip) 116 | expected_status = (time_offset % window) >= max_requests ? status_throttled : status_ok 117 | assert_equal expected_status, last_response.status, "offset #{time_offset}" 118 | end 119 | end 120 | end 121 | 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rack::Defense 2 | ============= 3 | 4 | A Rack middleware for throttling and filtering requests. 5 | 6 | [![Build Status](https://travis-ci.org/nakhli/rack-defense.svg)](https://travis-ci.org/nakhli/rack-defense) 7 | [![Security](https://hakiri.io/github/nakhli/rack-defense/master.svg)](https://hakiri.io/github/nakhli/rack-defense/master) 8 | [![Code Climate](https://codeclimate.com/github/nakhli/rack-defense/badges/gpa.svg)](https://codeclimate.com/github/nakhli/rack-defense) 9 | [![Gem Version](https://badge.fury.io/rb/rack-defense.svg)](http://badge.fury.io/rb/rack-defense) 10 | 11 | Rack::Defense is a Rack middleware that allows to easily add request rate limiting and request filtering to your Rack based application (Ruby On Rails, Sinatra etc.). 12 | 13 | * [Request throttling](#throttling) (aka rate limiting) happens on __sliding window__ using the provided period, request criteria and maximum request number. It uses Redis to track the request rate. 14 | 15 | * [Request filtering](#filtering) bans (rejects) requests based on provided criteria. 16 | 17 | Rack::Defense has a small footprint and only two dependencies: [rack](https://github.com/rack/rack) and [redis](https://github.com/redis/redis-rb). 18 | 19 | Rack::Defense is inspired from the [Rack::Attack](https://github.com/kickstarter/rack-attack) project. The main difference is the throttling algorithm: Rack::Attack uses a counter reset at the end of each period, therefore allowing up to 2 times more requests than the maximum rate specified. We use a sliding window algorithm allowing a precise request rate limiting. 20 | 21 | ## Getting started 22 | 23 | Install the rack-defense gem; or add it to you Gemfile with bundler: 24 | 25 | ```ruby 26 | # In your Gemfile 27 | gem 'rack-defense' 28 | ``` 29 | 30 | Tell your app to use the Rack::Defense middleware. For Rails 3+ apps: 31 | 32 | ```ruby 33 | # In config/application.rb 34 | config.middleware.use Rack::Defense 35 | ``` 36 | 37 | Or for Rackup files: 38 | 39 | ```ruby 40 | # In config.ru 41 | use Rack::Defense 42 | ``` 43 | 44 | Add a `rack-defense.rb` file to `config/initializers/`: 45 | 46 | ```ruby 47 | # In config/initializers/rack-defense.rb 48 | Rack::Defense.setup do |config| 49 | # your configuration here 50 | end 51 | ``` 52 | 53 | ## Throttling 54 | 55 | The Rack::Defense middleware evaluates the throttling criteria (lambdas) against the incoming request. 56 | If the return value is falsy, the request is not throttled. Otherwise, the returned value is used as a key to 57 | throttle the request. The returned key could be the request IP, user name, API token or any discriminator to throttle 58 | the requests against. 59 | 60 | ### Examples 61 | 62 | Throttle POST requests for path `/login` with a maximum rate of 3 request per minute per IP: 63 | 64 | ```ruby 65 | Rack::Defense.setup do |config| 66 | config.throttle('login', 3, 60 * 1000) do |req| 67 | req.ip if req.path == '/login' && req.post? 68 | end 69 | end 70 | ``` 71 | 72 | Throttle GET requests for path `/api/*` with a maximum rate of 50 request per second per API token: 73 | 74 | ```ruby 75 | Rack::Defense.setup do |config| 76 | config.throttle('api', 50, 1000) do |req| 77 | req.env['HTTP_AUTHORIZATION'] if %r{^/api/} =~ req.path 78 | end 79 | end 80 | ``` 81 | 82 | Throttle POST requests for path `/aggregate/report` with a maximum rate of 10 requests per hour for a given logged in user. We assume here that we are using the [Warden](https://github.com/hassox/warden) middleware for authentication or any Warden based authentication wrapper, like [Devise](https://github.com/plataformatec/devise) in Rails. 83 | 84 | ```ruby 85 | Rack::Defense.setup do |config| 86 | config.throttle('aggregate_report', 10, 1.hour.in_milliseconds) do |req| 87 | req.env['warden'].user.id if req.path == '/aggregate/report' && req.env['warden'].user 88 | end 89 | end 90 | ``` 91 | 92 | ### Redis Configuration 93 | 94 | Rack::Defense uses Redis to track request rates. By default, the `REDIS_URL` environment variable is used to setup 95 | the store. If not set, it falls back to host `127.0.0.1` port `6379`. 96 | The redis store can be setup with either a connection url: 97 | 98 | ```ruby 99 | Rack::Defense.setup do |config| 100 | config.store = "redis://:p4ssw0rd@10.0.1.1:6380/15" 101 | end 102 | ``` 103 | 104 | or directly with a connection object: 105 | 106 | ```ruby 107 | Rack::Defense.setup do |config| 108 | config.store = Redis.new(host: "10.0.1.1", port: 6380, db: 15) 109 | end 110 | ``` 111 | 112 | ## Filtering 113 | 114 | Rack::Defense can reject requests based on arbitrary properties of the request. Matching requests are filtered out. 115 | 116 | ### Examples 117 | 118 | Allow only a whitelist of IPs for a given path: 119 | 120 | ```ruby 121 | Rack::Defense.setup do |config| 122 | config.ban('ip_whitelist') do |req| 123 | req.path == '/protected' && !['192.168.0.1', '127.0.0.1'].include?(req.ip) 124 | end 125 | end 126 | ``` 127 | 128 | Deny access to a blacklist of application users. Again, we assume here that 129 | [Warden](https://github.com/hassox/warden) or any Warden based authentication wrapper, like [Devise](https://github.com/plataformatec/devise), is used: 130 | 131 | ```ruby 132 | Rack::Defense.setup do |config| 133 | config.ban('user_blacklist') do |req| 134 | ['hacker@example.com', 'badguy@example.com'].include? req.env['warden'].user.email 135 | end 136 | end 137 | ``` 138 | 139 | Allow only requests with a known API authorization token: 140 | 141 | ```ruby 142 | Rack::Defense.setup do |config| 143 | config.ban('validate_api_token') do |req| 144 | %r{^/api/} =~ req.path && Redis.current.sismember('apitokens', req.env['HTTP_AUTHORIZATION']) 145 | end 146 | end 147 | ``` 148 | 149 | The previous example uses redis to keep track of valid api tokens, but any store (database, key-value store etc.) would do here. 150 | 151 | ## Response configuration 152 | 153 | By default, Rack::Defense returns `429 Too Many Requests` and `403 Forbidden` respectively for throttled and banned requests. 154 | These responses can be fully configured in the setup: 155 | 156 | ```ruby 157 | Rack::Defense.setup do |config| 158 | config.banned_response = 159 | ->(env) { [404, {'Content-Type' => 'text/plain'}, ["Not Found\n"]] } 160 | config.throttled_response = 161 | ->(env) { [503, {'Content-Type' => 'text/plain'}, ["Service Unavailable\n"]] } 162 | end 163 | ``` 164 | 165 | ## Notifications 166 | 167 | You can be notified when requests are throttled or banned. The callback receives the throttled request object and data 168 | about the event context. 169 | 170 | For banned request callbacks, the triggered rule name is passed: 171 | 172 | ```ruby 173 | Rack::Defense.setup do |config| 174 | config.after_ban do |req, rule| 175 | logger.info "[Banned] #{rule} #{req.path} #{req.ip}" 176 | end 177 | end 178 | ``` 179 | 180 | For throttled request callbacks, a hash having triggered rule names as keys and the corresponding throttle keys 181 | as values is passed. 182 | 183 | ```ruby 184 | Rack::Defense.setup do |config| 185 | config.after_throttle do |req, rules| 186 | logger.info rules.map { |e| "[Throttled] rule name: #{e[0]} - rule throttle key: #{e[1]}" }.join ', ' 187 | end 188 | end 189 | ``` 190 | 191 | ## Advanced Examples 192 | 193 | ### Temporarily suspend access to suspicious IPs 194 | 195 | In this example, when an IP is exceeding the permitted request rate, we would like to ban this IP for a given period of time: 196 | 197 | ```ruby 198 | Rack::Defense.setup do |config| 199 | config.throttle('reset_password', 10, 10.minutes.in_milliseconds) do |req| 200 | req.ip if req.path == '/api/users/password' && req.post? 201 | end 202 | 203 | config.after_throttle do |req, rules| 204 | config.store.setex("ban:ip:#{req.ip}", 1.hour, 1) if rules.key? 'reset_password' 205 | end 206 | 207 | config.ban('blacklist') do |req| 208 | config.store.exists("ban:ip:#{req.ip}") 209 | end 210 | end 211 | ``` 212 | 213 | The first rule named `reset_password` defines the maximum permitted rate per IP for post requests on path 214 | `/api/users/password`. Once a user exceeds this limit, it gets throttled and denied access to the resource. 215 | This raises a throttle event and triggers the `after_throttle` callback defined above. The callback sets a key in the redis store post-fixed with the user IP and having 1 hour an expiration time. 216 | 217 | The last rule named `blacklist` looks up each incoming request IP and checks if it has a corresponding ban key 218 | in redis. If the request IP matches a ban key it gets denied. 219 | 220 | ## License 221 | 222 | Licensed under the [MIT License](http://opensource.org/licenses/MIT). 223 | 224 | Copyright Chaker Nakhli. 225 | 226 | --------------------------------------------------------------------------------