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 +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
|
[![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
|
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
|