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 +4 -4
- data/README.md +66 -14
- data/lib/rack/defense.rb +62 -17
- data/lib/rack/defense/throttle_counter.rb +3 -3
- data/lib/rack/defense/version.rb +1 -1
- data/spec/defense_callbacks_spec.rb +60 -0
- data/spec/spec_helper.rb +1 -1
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0c50757c35cc2da91b683042c9e3b756ec83512c
|
4
|
+
data.tar.gz: a491f2c431c5ccb04f5fd75b20314f6fc0e7a543
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
[](https://gemnasium.com/Sinbadsoft/rack-defense)
|
9
9
|
[](http://badge.fury.io/rb/rack-defense)
|
10
10
|
|
11
|
-
Rack::Defense is a Rack middleware that allows
|
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
|
-
*
|
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
|
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/
|
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
|
-
|
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 `/
|
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
|
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.
|
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
|
-
|
data/lib/rack/defense.rb
CHANGED
@@ -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
|
-
|
17
|
-
|
18
|
-
end
|
24
|
+
@ban_callbacks, @throttle_callbacks = [], []
|
25
|
+
end
|
19
26
|
|
20
|
-
def throttle(
|
21
|
-
|
22
|
-
|
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(
|
29
|
-
|
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
|
-
|
35
|
-
|
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
|
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.
|
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.
|
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
|
-
|
70
|
-
|
71
|
-
|
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
|
data/lib/rack/defense/version.rb
CHANGED
@@ -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
|
data/spec/spec_helper.rb
CHANGED
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.
|
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-
|
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
|