diff --git a/Gemfile.lock b/Gemfile.lock index 26edd99..3851e5b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,4 +39,4 @@ DEPENDENCIES yard BUNDLED WITH - 1.14.3 + 1.16.1 diff --git a/README.md b/README.md index d4b1755..6b7c0a9 100755 --- a/README.md +++ b/README.md @@ -124,6 +124,9 @@ Throttling Strategies * `Rack::Throttle::Second`: Throttles the application by defining a maximum number of allowed HTTP requests per second (by default, 1 request per second). +* `Rack::Throttle::Rules`: Throttles the application by defining + different rules of allowed HTTP request per time_window based on the + request method and the request paths, or use a default. You can fully customize the implementation details of any of these strategies by simply subclassing one of the aforementioned default implementations. @@ -152,6 +155,43 @@ class Rack::Throttle::RequestMethod < Rack::Throttle::Second end ``` +Passing the correct options for `Rules` strategy. + +``` +rules = [ + { method: "POST", limit: 5 }, + { method: "GET", limit: 10 }, + { method: "GET", path: "/users/.*/profile", limit: 3 }, + { method: "GET", path: "/users/.*/reset_password", limit: 1 } + { method: "GET", path: "/external/callback", whitelisted: true } +] +ip_whitelist = [ + "1.2.3.4", + "5.6.7.8" +] +default = 10 + + +use Rack::Throttle::Rules, rules: rules, ip_whitelist: ips, default: default +``` + +This configuration would allow a maximum of 3 profile requests per second (default), i +1 reset password requests per second, 5 POST and 10 GET requests per second +(always also based on the IPaddress). Additionally it would whitelist the external callback +and add a ip-whitelisting for the given ips. + +Rules are checked in this order: +* ip whitelist +* rules with `paths`, +* rules with `methods` only, +* `default`. + +It is possible to set the time window for this strategy to: `:second` (default), `:minute`, `:hour` or `:day`, to change the check interval to these windows. + +``` +use Rack::Throttle::MethodAndPath, limits: limits, time_window: :minute +``` + HTTP Client Identification -------------------------- diff --git a/lib/rack/throttle.rb b/lib/rack/throttle.rb index fefc05c..01a4937 100644 --- a/lib/rack/throttle.rb +++ b/lib/rack/throttle.rb @@ -9,6 +9,7 @@ module Throttle autoload :Hourly, ::File.expand_path(::File.dirname(__FILE__)) + '/throttle/hourly' autoload :Minute, ::File.expand_path(::File.dirname(__FILE__)) + '/throttle/minute' autoload :Second, ::File.expand_path(::File.dirname(__FILE__)) + '/throttle/second' + autoload :Rules, ::File.expand_path(::File.dirname(__FILE__)) + '/throttle/rules' autoload :VERSION, ::File.expand_path(::File.dirname(__FILE__)) + '/throttle/version' end end diff --git a/lib/rack/throttle/rules.rb b/lib/rack/throttle/rules.rb new file mode 100644 index 0000000..d5b52af --- /dev/null +++ b/lib/rack/throttle/rules.rb @@ -0,0 +1,93 @@ +module Rack + module Throttle + class Rules < TimeWindow + ## + # @param [#call] app + # @param [Hash{Symbol => Object}] options + # @option options [Integer] :max (1) + def initialize(app, options = {}) + super + end + + def rules + @rules ||= begin + rs = options[:rules] + rs.sort_by { |r| r[:path].to_s }.reverse + end + end + + def retry_after + @min ||= (options[:min] || 3600) + end + + def default_limit + @default_limit ||= options[:default] || 1_000_000_000 + end + + def ips + @ips ||= options[:ip_whitelist] || [] + end + + def whitelisted?(request) + return true if ip_whitelisted?(ip(request)) + return true if path_whitelisted?(request) + false + end + + def ip_whitelisted?(request_ip) + !!ips.find { |ip| ip.to_s == request_ip.to_s } + end + + def path_whitelisted?(request) + rule = rule_for(request) + rule ? rule[:whitelisted] : false + end + + def rule_for(request) + rules.find do |rule| + next unless rule[:method] == request.request_method.to_s + next unless path_matches?(rule, request.path.to_s) + rule + end + end + + def path_matches?(rule, path) + return true unless rule[:path] + return true if path.to_s.match(rule[:path]) + false + end + + def max_per_window(request) + rule = rule_for(request) + rule ? rule[:limit] : default_limit + end + + def client_identifier(request) + if (rule = rule_for(request)) + "#{ip(request)}_#{rule[:method]}_#{rule[:path]}" + else + ip(request) + end + end + + def ip(request) + request.ip.to_s + end + + def cache_key(request) + [super, Time.now.strftime(time_string)].join(':') + end + + def time_string + @time_string ||= case options[:time_window] + when :second then '%Y-%m-%dT%H:%M:%S' + when :minute then '%Y-%m-%dT%H:%M' + when :hour then '%Y-%m-%dT%H' + when :day then '%Y-%m-%d' + else '%Y-%m-%dT%H:%M:%S' + end + end + end + end +end + diff --git a/lib/rack/throttle/version.rb b/lib/rack/throttle/version.rb index 197510e..c186f45 100644 --- a/lib/rack/throttle/version.rb +++ b/lib/rack/throttle/version.rb @@ -1,8 +1,8 @@ module Rack; module Throttle module VERSION MAJOR = 0 - MINOR = 4 - TINY = 2 + MINOR = 5 + TINY = 0 EXTRA = nil STRING = [MAJOR, MINOR, TINY].join('.') diff --git a/spec/rules_spec.rb b/spec/rules_spec.rb new file mode 100644 index 0000000..e58a854 --- /dev/null +++ b/spec/rules_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +describe Rack::Throttle::Rules do + include Rack::Test::Methods + + include_context 'mock app' + + let(:options) do + { + rules: [ + { method: "POST", limit: 5 }, + { method: "GET", limit: 3 }, + { method: "GET", path: "/bar/.*/muh", limit: 5 }, + { method: "GET", path: "/white/list/me", whitelisted: true } + ], + ip_whitelist: [ + "123.123.123.123" + ], + default: 10 + } + end + + let(:app) { described_class.new(target_app, options) } + + describe "allowed" do + + it "should be allowed if not seen this second" do + allow_any_instance_of(Rack::Throttle::Rules).to receive(:ip).and_return("123.123.123.123") + 10.times { get "/bar/124/muh" } + expect(last_response.body).to show_allowed_response + end + + it "should be allowed unlimited times" do + 100.times { get "/white/list/me" } + expect(last_response.body).to show_allowed_response + end + + it "should be allowed if not seen this second" do + get "/bar/124/muh" + expect(last_response.body).to show_allowed_response + end + + it "should be allowed if not seen this second" do + 5.times { get "/bar/124/muh" } + expect(last_response.body).to show_allowed_response + end + + it "should be allowed if not seen this second" do + get "/foo" + expect(last_response.body).to show_allowed_response + end + + it "should be allowed if seen fewer times than the max allowed per second" do + 3.times { get "/foo" } + expect(last_response.body).to show_allowed_response + end + + it "should be allowed if seen fewer times than the max allowed per second" do + 5.times { post "/foo" } + expect(last_response.body).to show_allowed_response + end + + it "should be allowed if seen fewer than the default limit" do + 9.times { put "/foo" } + expect(last_response.body).to show_allowed_response + end + + [:second_ago, :minute_ago, :hour_ago, :day_ago].each do |time| + it "should not count the requests from a #{time.to_s.split('_').join(' ')} against this second" do + Timecop.freeze(1.send(time)) do + 20.times { get "/foo" } + expect(last_response.body).to show_throttled_response + end + + get "/foo" + expect(last_response.body).to show_allowed_response + end + end + + end + + describe "throttled" do + + it "should not be allowed if seen more times than the max allowed per second" do + 10.times { get "/foo" } + expect(last_response.body).to show_throttled_response + end + + it "should not be allowed if seen more times than the max allowed per second" do + 10.times { post "/foo" } + expect(last_response.body).to show_throttled_response + end + + it "should not be allowed if seen more times than the default defines" do + 15.times { put "/foo" } + expect(last_response.body).to show_throttled_response + end + + it "should be allowed if not seen this second" do + 6.times { get "/bar/124/muh" } + expect(last_response.body).to show_throttled_response + end + + it "should be allowed if not seen this second" do + allow_any_instance_of(Rack::Throttle::Rules).to receive(:ip).and_return("1.1.1.1") + 10.times { get "/bar/124/muh" } + expect(last_response.body).to show_throttled_response + end + + end + +end