rack-defense 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 9e47fe356fd72d70737af5ff0d64db8f823e1e93
4
- data.tar.gz: 62972c5a9e0258ffccd3af644d9e191526686e2a
3
+ metadata.gz: 0c50757c35cc2da91b683042c9e3b756ec83512c
4
+ data.tar.gz: a491f2c431c5ccb04f5fd75b20314f6fc0e7a543
5
5
  SHA512:
6
- metadata.gz: e09969ed7c4615439aa3f4dd105b992925cf48e7da9b0549f44c3f7757cbf8e4c46cdc5380edf0a66a5d3bb7c1bb8c2505062393a6600003dce6531f8b4f2840
7
- data.tar.gz: c7258b578750efd00ce4046c8f3f6fa6daf97a0390a80b1a10df887bc22edf91894f7c6f49b00bfeda59724288c22dfc4e8fe776041ac457425c57a96d3b36e0
6
+ metadata.gz: bc05d56c2f0cfa059aee32d9d2a0158ae8f53f74eed2f8abdf69f051dc6cd3d3268ed4f7d260fc55bbd0744dc22c6cb0fd1d5b6228a66363f3ad5c97a7ab3d78
7
+ data.tar.gz: d99193f5823229cc5f155bf7b1f5cb1e7eab49af9aa7bf7aa733890a6fdafc35735a92ccab916dcafa2f4be6642235d8c9deac8212b43b148198c30eb545645e
data/README.md CHANGED
@@ -8,11 +8,11 @@ A Rack middleware for throttling and filtering requests.
8
8
  [![Dependency Status](https://gemnasium.com/Sinbadsoft/rack-defense.svg)](https://gemnasium.com/Sinbadsoft/rack-defense)
9
9
  [![Gem Version](https://badge.fury.io/rb/rack-defense.svg)](http://badge.fury.io/rb/rack-defense)
10
10
 
11
- Rack::Defense is a Rack middleware that allows you to easily add request rate limiting and request filtering to your Rack based application (Ruby On Rails, Sinatra etc.).
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
12
 
13
- * 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.
13
+ * Request 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
14
 
15
- * Request filtering allows to reject requests based on provided critera.
15
+ * Request filtering bans (rejects) requests based on provided criteria.
16
16
 
17
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
18
 
@@ -26,19 +26,23 @@ Install the rack-defense gem; or add it to you Gemfile with bundler:
26
26
  # In your Gemfile
27
27
  gem 'rack-defense'
28
28
  ```
29
+
29
30
  Tell your app to use the Rack::Defense middleware. For Rails 3+ apps:
30
- ```
31
+
32
+ ```ruby
31
33
  # In config/application.rb
32
34
  config.middleware.use Rack::Defense
33
35
  ```
34
36
 
35
37
  Or for Rackup files:
36
- ```
38
+
39
+ ```ruby
37
40
  # In config.ru
38
41
  use Rack::Defense
39
42
  ```
40
43
 
41
- Add a `rack-defense.rb` file to `config/initalizers/`:
44
+ Add a `rack-defense.rb` file to `config/initializers/`:
45
+
42
46
  ```ruby
43
47
  # In config/initializers/rack-defense.rb
44
48
  Rack::Defense.setup do |config|
@@ -47,11 +51,16 @@ end
47
51
  ```
48
52
 
49
53
  ## Throttling
50
- The Rack::Defense middleware evaluates the throttling criterias (lambdas) against the incoming request. If the return value is falsy, the request is not throttled. Otherwise, the returned value is used as a key to throttle the request. The returned key could be the request IP, user name, API token or any discriminator to throttle the requests against.
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.
51
59
 
52
60
  ### Examples
53
61
 
54
- Throttle POST requests for path `/login` with a maximum rate of 3 request per minute per IP
62
+ Throttle POST requests for path `/login` with a maximum rate of 3 request per minute per IP:
63
+
55
64
  ```ruby
56
65
  Rack::Defense.setup do |config|
57
66
  config.throttle('login', 3, 60 * 1000) do |req|
@@ -60,7 +69,8 @@ Rack::Defense.setup do |config|
60
69
  end
61
70
  ```
62
71
 
63
- Throttle GET requests for path `/image` with a maximum rate of 50 request per second per API token
72
+ Throttle GET requests for path `/api/*` with a maximum rate of 50 request per second per API token:
73
+
64
74
  ```ruby
65
75
  Rack::Defense.setup do |config|
66
76
  config.throttle('api', 50, 1000) do |req|
@@ -69,16 +79,30 @@ Rack::Defense.setup do |config|
69
79
  end
70
80
  ```
71
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
+
72
92
  ### Redis Configuration
73
93
 
74
- Rack::Defense uses Redis to track request rates. By default, the `REDIS_URL` environment variable is used to setup the store. If not set, it falls back to host `127.0.0.1` port `6379`.
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`.
75
96
  The redis store can be setup with either a connection url:
97
+
76
98
  ```ruby
77
99
  Rack::Defense.setup do |config|
78
100
  config.store = "redis://:p4ssw0rd@10.0.1.1:6380/15"
79
101
  end
80
102
  ```
103
+
81
104
  or directly with a connection object:
105
+
82
106
  ```ruby
83
107
  Rack::Defense.setup do |config|
84
108
  config.store = Redis.new(host: "10.0.1.1", port: 6380, db: 15)
@@ -90,7 +114,9 @@ end
90
114
  Rack::Defense can reject requests based on arbitrary properties of the request. Matching requests are filtered.
91
115
 
92
116
  ### Examples
117
+
93
118
  Allow only a whitelist of ips for a given path:
119
+
94
120
  ```ruby
95
121
  Rack::Defense.setup do |config|
96
122
  config.ban('ip_whitelist') do |req|
@@ -100,6 +126,7 @@ end
100
126
  ```
101
127
 
102
128
  Allow only requests with a known API authorization token:
129
+
103
130
  ```ruby
104
131
  Rack::Defense.setup do |config|
105
132
  config.ban('validate_api_token') do |req|
@@ -108,17 +135,44 @@ Rack::Defense.setup do |config|
108
135
  end
109
136
  ```
110
137
 
138
+ The previous example uses redis to keep track of valid api tokens, but any store (database, key-value store etc.) would do here.
139
+
111
140
  ## Response configuration
112
141
 
113
- By default, Rack::Defense returns `429 Too Many Requests` and `403 Forbidden` respectively for throttled and banned requests. These responses can be fully configured in the setup:
142
+ By default, Rack::Defense returns `429 Too Many Requests` and `403 Forbidden` respectively for throttled and banned requests.
143
+ These responses can be fully configured in the setup:
114
144
 
115
145
  ```ruby
116
146
  Rack::Defense.setup do |config|
117
147
  config.banned_response =
118
148
  ->(env) { [404, {'Content-Type' => 'text/plain'}, ["Not Found\n"]] }
119
-
120
149
  config.throttled_response =
121
150
  ->(env) { [503, {'Content-Type' => 'text/plain'}, ["Service Unavailable\n"]] }
151
+ end
152
+ ```
153
+
154
+ ## Notifications
155
+
156
+ You can be notified when requests are throttled or banned. The callback receives the throttled request object and data
157
+ about the event context.
158
+
159
+ For banned request callbacks, the triggered rule name is passed:
160
+
161
+ ```ruby
162
+ Rack::Defense.setup do |config|
163
+ config.after_ban do |req, rule|
164
+ logger.info "[Banned] #{rule} #{req.path} #{req.ip}"
165
+ end
166
+ end
167
+ ```
168
+
169
+ For throttled request callbacks, a hash having triggered rule names as keys and the corresponding throttle keys
170
+ as values is passed.
171
+
172
+ ```ruby
173
+ Rack::Defense.setup do |config|
174
+ config.after_throttle do |req, rules|
175
+ logger.info rules.map { |e| "rule name: #{e[0]} - rule throttle key: #{e[1]}" }.join ', '
122
176
  end
123
177
  end
124
178
  ```
@@ -129,5 +183,3 @@ Licensed under the [MIT License](http://opensource.org/licenses/MIT).
129
183
 
130
184
  Copyright Sinbadsoft.
131
185
 
132
-
133
-
@@ -6,33 +6,53 @@ class Rack::Defense
6
6
  autoload :ThrottleCounter, 'rack/defense/throttle_counter'
7
7
 
8
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
+
9
12
  attr_accessor :banned_response
10
13
  attr_accessor :throttled_response
14
+
11
15
  attr_reader :bans
12
16
  attr_reader :throttles
17
+ attr_reader :ban_callbacks
18
+ attr_reader :throttle_callbacks
13
19
 
14
20
  def initialize
21
+ self.banned_response = BANNED_RESPONSE
22
+ self.throttled_response = THROTTLED_RESPONSE
15
23
  @throttles, @bans = {}, {}
16
- self.banned_response = ->(env) { [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] }
17
- self.throttled_response = ->(env) { [429, {'Content-Type' => 'text/plain'}, ["Retry later\n"]] }
18
- end
24
+ @ban_callbacks, @throttle_callbacks = [], []
25
+ end
19
26
 
20
- def throttle(name, max_requests, period, &block)
21
- counter = ThrottleCounter.new(name, max_requests, period, store)
22
- throttles[name] = lambda do |req|
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|
23
31
  key = block.call(req)
24
- key && counter.throttle?(key)
32
+ key if key && counter.throttle?(key)
25
33
  end
26
34
  end
27
35
 
28
- def ban(name, &block)
29
- bans[name] = block
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
30
47
  end
31
48
 
32
49
  def store=(value)
33
50
  value = Redis.new(url: value) if value.is_a?(String)
34
- @store = SimpleDelegator::new(value) unless @store
35
- @store.__setobj__(value)
51
+ if @store
52
+ @store.__setobj__(value)
53
+ else
54
+ @store = SimpleDelegator.new(value)
55
+ end
36
56
  end
37
57
 
38
58
  def store
@@ -45,17 +65,25 @@ class Rack::Defense
45
65
  class << self
46
66
  attr_accessor :config
47
67
 
48
- def setup(&block)
68
+ def setup
49
69
  self.config = Config.new
50
70
  yield config
51
71
  end
52
72
 
53
73
  def ban?(req)
54
- config.bans.any? { |name, filter| filter.call(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
55
78
  end
56
79
 
57
80
  def throttle?(req)
58
- config.throttles.any? { |name, filter| filter.call(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?
59
87
  end
60
88
  end
61
89
 
@@ -66,8 +94,25 @@ class Rack::Defense
66
94
  def call(env)
67
95
  klass, config = self.class, self.class.config
68
96
  req = ::Rack::Request.new(env)
69
- return config.banned_response.call(env) if klass.ban?(req)
70
- return config.throttled_response.call(env) if klass.throttle?(req)
71
- @app.call(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
72
117
  end
73
118
  end
@@ -1,14 +1,15 @@
1
1
  module Rack
2
2
  class Defense
3
3
  class ThrottleCounter
4
-
5
4
  KEY_PREFIX = 'rack-defense'
6
5
 
7
- attr_accessor :logger
8
6
  attr_accessor :name
9
7
 
10
8
  def initialize(name, max_requests, time_period, store)
11
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
12
13
  @store = store
13
14
  end
14
15
 
@@ -30,7 +31,6 @@ module Rack
30
31
  LUA_SCRIPT
31
32
 
32
33
  private_constant :SCRIPT
33
-
34
34
  end
35
35
  end
36
36
  end
@@ -1,5 +1,5 @@
1
1
  module Rack
2
2
  class Defense
3
- VERSION = '0.1.1'
3
+ VERSION = '0.2.0'
4
4
  end
5
5
  end
@@ -0,0 +1,60 @@
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
+ puts
54
+ assert_equal matching_request_count, trace.length
55
+ data = trace[-1]
56
+ # check callback data
57
+ assert_equal req_path, data[0].path
58
+ assert_equal rule_data, data[1]
59
+ end
60
+ end
@@ -14,7 +14,7 @@ class MiniTest::Spec
14
14
  def app
15
15
  Rack::Builder.new {
16
16
  use Rack::Defense
17
- run ->(env) { [200, {}, ['Hello World']] }
17
+ run ->(_) { [200, {}, ['Hello World']] }
18
18
  }.to_app
19
19
  end
20
20
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-defense
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chaker Nakhli
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-10-10 00:00:00.000000000 Z
11
+ date: 2014-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -107,6 +107,7 @@ files:
107
107
  - lib/rack/defense/throttle_counter.rb
108
108
  - lib/rack/defense/version.rb
109
109
  - spec/defense_ban_spec.rb
110
+ - spec/defense_callbacks_spec.rb
110
111
  - spec/defense_config_spec.rb
111
112
  - spec/defense_throttle_spec.rb
112
113
  - spec/spec_helper.rb
@@ -137,8 +138,9 @@ signing_key:
137
138
  specification_version: 4
138
139
  summary: Throttle and filter requests
139
140
  test_files:
140
- - spec/spec_helper.rb
141
- - spec/throttle_counter_spec.rb
142
- - spec/defense_config_spec.rb
143
141
  - spec/defense_ban_spec.rb
142
+ - spec/defense_callbacks_spec.rb
143
+ - spec/defense_config_spec.rb
144
144
  - spec/defense_throttle_spec.rb
145
+ - spec/spec_helper.rb
146
+ - spec/throttle_counter_spec.rb