rack-defense 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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