├── Rakefile ├── lib ├── limiter │ ├── version.rb │ ├── black_list.rb │ ├── white_list.rb │ ├── visit_counter.rb │ ├── rate_limiter.rb │ └── base.rb └── limiter.rb ├── Gemfile ├── .gitignore ├── limiter.gemspec ├── LICENSE.txt └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /lib/limiter/version.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Limiter 3 | VERSION = "0.0.3" 4 | end 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in limiter.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/limiter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require "limiter/version" 3 | require "limiter/base" 4 | require "limiter/white_list" 5 | require "limiter/black_list" 6 | require "limiter/rate_limiter" 7 | require "limiter/visit_counter" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *~ 19 | nbproject/* 20 | *.swp 21 | -------------------------------------------------------------------------------- /lib/limiter/black_list.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Limiter 3 | class BlackList 4 | def initialize(cache_store) 5 | @cache_store = cache_store 6 | end 7 | 8 | def list 9 | @cache_store.smembers(key) 10 | end 11 | 12 | def add(ip) 13 | @cache_store.sadd(key, ip) 14 | end 15 | 16 | def remove(ip) 17 | @cache_store.srem(key, ip) 18 | end 19 | 20 | def member?(ip) 21 | @cache_store.sismember(key, ip) 22 | end 23 | 24 | def key 25 | "limiter/black_list" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/limiter/white_list.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Limiter 3 | class WhiteList 4 | def initialize(cache_store) 5 | @cache_store = cache_store 6 | end 7 | 8 | def list 9 | @cache_store.smembers(key) 10 | end 11 | 12 | def add(ip) 13 | @cache_store.sadd(key, ip) 14 | end 15 | 16 | def remove(ip) 17 | @cache_store.srem(key, ip) 18 | end 19 | 20 | def member?(ip) 21 | @cache_store.sismember(key, ip) 22 | end 23 | 24 | def key 25 | "limiter/white_list" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /limiter.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'limiter/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "limiter" 8 | gem.version = Limiter::VERSION 9 | gem.authors = ["wangxz"] 10 | gem.email = ["wangxz@csdn.net"] 11 | gem.description = %q{Write a gem description} 12 | gem.summary = %q{Write a gem summary} 13 | gem.homepage = "" 14 | 15 | gem.files = Dir.glob("lib/**/*") + [ 16 | "LICENSE.txt", 17 | "README.md", 18 | "Rakefile", 19 | "Gemfile", 20 | "limiter.gemspec" 21 | ] 22 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 23 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 24 | gem.require_paths = ["lib"] 25 | end 26 | -------------------------------------------------------------------------------- /lib/limiter/visit_counter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Limiter 3 | class VisitCounter 4 | def initialize(cache_store) 5 | @cache_store = cache_store 6 | end 7 | 8 | def remove(ip, method) 9 | @cache_store.del cache_key(ip, method) 10 | end 11 | 12 | def incr(ip, method, ttl) 13 | @cache_store.multi do 14 | @cache_store.incr cache_key(ip, method) 15 | @cache_store.expire(cache_key(ip, method), ttl) 16 | end 17 | end 18 | 19 | def count(ip, method) 20 | @cache_store.get(cache_key(ip, method)).to_i 21 | end 22 | 23 | def set(ip, method, ttl, num) 24 | @cache_store.setex(cache_key(ip, method), ttl, num) 25 | end 26 | 27 | def remove_both(ip) 28 | remove ip, 'GET' 29 | remove ip, 'POST' 30 | end 31 | 32 | private 33 | def cache_key(ip, method) 34 | ['limiter/vc', ip, method].join('/') 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 wangxz 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/limiter/rate_limiter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Limiter 3 | class RateLimiter < Base 4 | GET_TTL = 20.minutes 5 | MAX_GET_NUM = 1000 6 | 7 | POST_TTL = 5.seconds 8 | MAX_POST_NUM = 20 9 | 10 | attr_reader :max_get_num 11 | attr_reader :max_post_num 12 | attr_reader :get_ttl 13 | attr_reader :post_ttl 14 | 15 | def initialize(app, options = {}) 16 | super 17 | @max_get_num = options[:max_get_num] || MAX_GET_NUM 18 | @max_post_num = options[:max_post_num] || MAX_POST_NUM 19 | @post_ttl = options[:post_ttl] || POST_TTL 20 | @get_ttl = options[:get_ttl] || GET_TTL 21 | end 22 | 23 | def visit_counter 24 | @visit_counter ||= options[:visit_counter] 25 | end 26 | 27 | def allowed?(request) 28 | common_allowed = super 29 | return common_allowed unless common_allowed.nil? 30 | 31 | client_id = client_identifier(request) 32 | post_count = read_and_incr_post_num(request, client_id) 33 | get_count = read_and_incr_get_num(request, client_id) 34 | 35 | if (get_count > max_get_num || post_count > max_post_num) 36 | limit_callback.call(client_id) if limit_callback 37 | false 38 | else 39 | true 40 | end 41 | end 42 | 43 | def client_identifier(request) 44 | if @filter_ip_segment 45 | # 61.135.163.4 -> 61.135.163.0 46 | super(request).sub(/\.\d+$/, ".0") 47 | else 48 | # 61.135.163.4 49 | super(request) 50 | end 51 | end 52 | 53 | private 54 | 55 | def read_and_incr_post_num(request, client_id) 56 | if request.post? 57 | post_count = visit_counter.count(client_id, "POST") 58 | visit_counter.incr(client_id, "POST", post_ttl) 59 | return post_count 60 | end 61 | return 0 62 | end 63 | 64 | def read_and_incr_get_num(request, client_id) 65 | get_count = visit_counter.count(client_id, "GET") 66 | visit_counter.incr(client_id, "GET", get_ttl) 67 | return get_count 68 | end 69 | 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Limiter 2 | 3 | Rack middleware for rate-limiting incoming HTTP requests with black_list and white_list support. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | gem 'limiter', :git => "git://github.com/csdn-dev/limiter.git" 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | ## Usage 16 | 17 | ```ruby 18 | # config/initializers/limiter.rb 19 | 20 | # -*- encoding : utf-8 -*- 21 | require File.expand_path("../redis", __FILE__) 22 | Rails.configuration.app_middleware.insert_before(Rack::MethodOverride, 23 | Limiter::RateLimiter, 24 | :max_get_num => 1000, 25 | :get_ttl => 20.minutes, 26 | 27 | :max_post_num => 20, 28 | :post_ttl => 5.seconds, 29 | :filter_ip_segment => true, # default true 30 | :black_list => Limiter::BlackList.new($redis), 31 | :white_list => Limiter::WhiteList.new($redis), 32 | :allow_path => Rails.env.development? ? /^\/(assets|human_validations|simple_captcha)/ : 33 | /^\/(human_validations|simple_captcha)/, 34 | :message => "我不是机器人", 35 | :visit_counter => Limiter::VisitCounter.new($redis), 36 | :limit_callback => lambda { |ip| your_callback(ip) } 37 | ) 38 | ``` 39 | 40 | ## Contributing 41 | 42 | 1. Fork it 43 | 2. Create your feature branch (`git checkout -b my-new-feature`) 44 | 3. Commit your changes (`git commit -am 'Add some feature'`) 45 | 4. Push to the branch (`git push origin my-new-feature`) 46 | 5. Create new Pull Request 47 | -------------------------------------------------------------------------------- /lib/limiter/base.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | # This is the base class for rate limiter implementations. 3 | # 4 | # @example Defining a rate limiter subclass 5 | # class MyLimiter < Limiter::Base 6 | # def allowed?(request) 7 | # # TODO: custom logic goes here 8 | # end 9 | # end 10 | # 11 | module Limiter 12 | class Base 13 | attr_reader :app 14 | attr_reader :options 15 | attr_reader :white_list 16 | attr_reader :black_list 17 | attr_reader :allow_path 18 | attr_reader :allow_agent 19 | attr_reader :limit_callback 20 | attr_reader :filter_ip_segment 21 | 22 | ## 23 | # @param [#call] app 24 | # @param [Hash{Symbol => Object}] options 25 | # @option options [BlackList] :black_list (BlackList.new($redis)) 26 | # @option options [WhiteList] :white_list (WhiteList.new($redis)) 27 | # @option options [String/Regexp] :allow_path ("/human_test") 28 | # @option options [Regex] :allow_agent (/agent1|agent2/) 29 | # @option options [Integer] :code (403) 30 | # @option options [String] :message ("Rate Limit Exceeded") 31 | 32 | def initialize(app, options = {}) 33 | @black_list = options[:black_list] 34 | @white_list = options[:white_list] 35 | @allow_path = options[:allow_path] 36 | @allow_agent = options[:allow_agent] 37 | @filter_ip_segment = (options[:filter_ip_segment] || options[:filter_ip_segment].nil?) ? true : false 38 | @app, @options = app, options 39 | @limit_callback = options[:limit_callback] 40 | end 41 | 42 | ## 43 | # @param [Hash{String => String}] env 44 | # @return [Array(Integer, Hash, #each)] 45 | # @see http://rack.rubyforge.org/doc/SPEC.html 46 | def call(env) 47 | request = Rack::Request.new(env) 48 | allowed?(request) ? app.call(env) : rate_limit_exceeded 49 | end 50 | 51 | ## 52 | # Returns `false` if the rate limit has been exceeded for the given 53 | # `request`, or `true` otherwise. 54 | # 55 | # Override this method in subclasses that implement custom rate limiter 56 | # strategies. 57 | # 58 | # @param [Rack::Request] request 59 | # @return [Boolean] 60 | def allowed?(request) 61 | case 62 | when allow_path?(request) then true 63 | when allow_agent?(request) then true 64 | when whitelisted?(request) then true 65 | when blacklisted?(request) then false 66 | else nil # override in subclasses 67 | end 68 | end 69 | 70 | def whitelisted?(request) 71 | white_list.member?(client_identifier(request)) 72 | end 73 | 74 | def blacklisted?(request) 75 | black_list.member?(client_identifier(request)) 76 | end 77 | 78 | def allow_path?(request) 79 | if allow_path.is_a?(Regexp) 80 | request.path =~ allow_path 81 | else 82 | request.path == allow_path 83 | end 84 | end 85 | 86 | def allow_agent?(request) 87 | return false unless allow_agent 88 | request.user_agent.to_s =~ allow_agent 89 | end 90 | 91 | protected 92 | 93 | ## 94 | # 使用proxy,内网ip访问时,request.ip只会返回127.0.0.1 95 | # 需要在前端设置 HTTP_X_REAL_IP 来取得内网真实 client ip 96 | # @param [Rack::Request] request 97 | # @return [String] 98 | def client_identifier(request) 99 | ip = request.ip.to_s 100 | ip = request.env['HTTP_X_REAL_IP'] if ip == '127.0.0.1' && request.env.has_key?('HTTP_X_REAL_IP') && 101 | request.trusted_proxy?(request.env['HTTP_X_REAL_IP']) 102 | return ip 103 | end 104 | 105 | ## 106 | # @param [Rack::Request] request 107 | # @return [Float] 108 | def request_start_time(request) 109 | case 110 | when request.env.has_key?('HTTP_X_REQUEST_START') 111 | request.env['HTTP_X_REQUEST_START'].to_f / 1000 112 | else 113 | Time.now.to_f 114 | end 115 | end 116 | 117 | ## 118 | # Outputs a `Rate Limit Exceeded` error. 119 | # 120 | # @return [Array(Integer, Hash, #each)] 121 | def rate_limit_exceeded 122 | headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {} 123 | http_error(options[:code] || 403, options[:message], headers) 124 | end 125 | 126 | ## 127 | # Outputs an HTTP `4xx` or `5xx` response. 128 | # 129 | # @param [Integer] code 130 | # @param [String, #to_s] message 131 | # @param [Hash{String => String}] headers 132 | # @return [Array(Integer, Hash, #each)] 133 | def http_error(code, message = nil, headers = {}) 134 | body = if message 135 | [message] 136 | else 137 | [http_status(code) + " : Rate Limit Exceeded\n"] 138 | end 139 | [code, {'Content-Type' => 'text/html; charset=utf-8'}.merge(headers), body] 140 | end 141 | 142 | ## 143 | # Returns the standard HTTP status message for the given status `code`. 144 | # 145 | # @param [Integer] code 146 | # @return [String] 147 | def http_status(code) 148 | [code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ') 149 | end 150 | end 151 | end 152 | --------------------------------------------------------------------------------