Skip to content

Commit

Permalink
Add rule based strategy
Browse files Browse the repository at this point in the history
Enable override of ip from outside
Add retry after option
Add ip and rule whitelisting
Adapt README
  • Loading branch information
TheWudu committed Sep 20, 2018
1 parent 16b953f commit e245d27
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 3 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Expand Up @@ -39,4 +39,4 @@ DEPENDENCIES
yard

BUNDLED WITH
1.14.3
1.16.1
40 changes: 40 additions & 0 deletions README.md
Expand Up @@ -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.
Expand Down Expand Up @@ -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
--------------------------
Expand Down
1 change: 1 addition & 0 deletions lib/rack/throttle.rb
Expand Up @@ -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
93 changes: 93 additions & 0 deletions 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

4 changes: 2 additions & 2 deletions 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('.')
Expand Down
112 changes: 112 additions & 0 deletions 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

0 comments on commit e245d27

Please sign in to comment.