rack-attack 6.0.0 → 6.3.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 +19 -5
- data/lib/rack/attack.rb +97 -146
- data/lib/rack/attack/cache.rb +15 -1
- data/lib/rack/attack/check.rb +2 -1
- data/lib/rack/attack/configuration.rb +107 -0
- data/lib/rack/attack/path_normalizer.rb +20 -18
- data/lib/rack/attack/railtie.rb +13 -0
- data/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb +3 -1
- data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +3 -1
- data/lib/rack/attack/store_proxy/redis_proxy.rb +16 -7
- data/lib/rack/attack/throttle.rb +32 -14
- data/lib/rack/attack/track.rb +6 -5
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/rails_middleware_spec.rb +35 -0
- data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +1 -3
- data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +7 -1
- data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +6 -1
- data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +3 -3
- data/spec/acceptance/throttling_spec.rb +19 -1
- data/spec/allow2ban_spec.rb +17 -14
- data/spec/fail2ban_spec.rb +17 -16
- data/spec/integration/offline_spec.rb +46 -1
- data/spec/rack_attack_instrumentation_spec.rb +1 -1
- data/spec/rack_attack_path_normalizer_spec.rb +2 -2
- data/spec/rack_attack_spec.rb +58 -13
- data/spec/rack_attack_throttle_spec.rb +43 -18
- data/spec/rack_attack_track_spec.rb +8 -5
- data/spec/spec_helper.rb +7 -9
- metadata +31 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cba47e380843d184fd3df1af08b252aca3fc2411de7ccbd62a9b7da6ee933b72
|
4
|
+
data.tar.gz: 0dc5300a553830ca7e1cfa84de814fd743e608b9ec0140fd6b1145c421085ba7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 71fd5eace9c851dab06317f3f4f4f28a2cbc20dd20c663228fbd073a052b4f30c4de87a06109ce9cf6b7395fc547b0c99b6f4e579285a699be40a93a9511452f
|
7
|
+
data.tar.gz: 5139f0be932f94273dba2d8d59ccbcb0e14ca92656b68d126099a124f04160c2c426e72f330b9e3c5dd8ed560f2fb292aa68dde2c32f2238d36c8c7e95422d01
|
data/README.md
CHANGED
@@ -1,3 +1,6 @@
|
|
1
|
+
__Note__: You are viewing the development version README.
|
2
|
+
For the README consistent with the latest released version see https://github.com/kickstarter/rack-attack/blob/6-stable/README.md.
|
3
|
+
|
1
4
|
# Rack::Attack
|
2
5
|
|
3
6
|
*Rack middleware for blocking & throttling abusive requests*
|
@@ -9,6 +12,7 @@ See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-ha
|
|
9
12
|
[](https://badge.fury.io/rb/rack-attack)
|
10
13
|
[](https://travis-ci.org/kickstarter/rack-attack)
|
11
14
|
[](https://codeclimate.com/github/kickstarter/rack-attack)
|
15
|
+
[](https://gitter.im/rack-attack/rack-attack)
|
12
16
|
|
13
17
|
## Table of contents
|
14
18
|
|
@@ -67,14 +71,19 @@ Or install it yourself as:
|
|
67
71
|
|
68
72
|
Then tell your ruby web application to use rack-attack as a middleware.
|
69
73
|
|
70
|
-
a) For __rails__ applications:
|
71
|
-
|
74
|
+
a) For __rails__ applications with versions >= 5.1 it is used by default. For older rails versions you should enable it explicitly:
|
72
75
|
```ruby
|
73
76
|
# In config/application.rb
|
74
77
|
|
75
78
|
config.middleware.use Rack::Attack
|
76
79
|
```
|
77
80
|
|
81
|
+
You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing:
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
Rack::Attack.enabled = false
|
85
|
+
```
|
86
|
+
|
78
87
|
b) For __rack__ applications:
|
79
88
|
|
80
89
|
```ruby
|
@@ -285,9 +294,9 @@ Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
|
|
285
294
|
end
|
286
295
|
|
287
296
|
# Track it using ActiveSupport::Notification
|
288
|
-
ActiveSupport::Notifications.subscribe("
|
297
|
+
ActiveSupport::Notifications.subscribe("track.rack_attack") do |name, start, finish, request_id, payload|
|
289
298
|
req = payload[:request]
|
290
|
-
if req.env['rack.attack.matched'] == "special_agent"
|
299
|
+
if req.env['rack.attack.matched'] == "special_agent"
|
291
300
|
Rails.logger.info "special_agent: #{req.path}"
|
292
301
|
STATSD.increment("special_agent")
|
293
302
|
end
|
@@ -333,6 +342,11 @@ end
|
|
333
342
|
While Rack::Attack's primary focus is minimizing harm from abusive clients, it
|
334
343
|
can also be used to return rate limit data that's helpful for well-behaved clients.
|
335
344
|
|
345
|
+
If you want to return to user how many seconds to wait until he can start sending requests again, this can be done through enabling `Retry-After` header:
|
346
|
+
```ruby
|
347
|
+
Rack::Attack.throttled_response_retry_after_header = true
|
348
|
+
```
|
349
|
+
|
336
350
|
Here's an example response that includes conventional `RateLimit-*` headers:
|
337
351
|
|
338
352
|
```ruby
|
@@ -354,7 +368,7 @@ end
|
|
354
368
|
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
|
355
369
|
|
356
370
|
```ruby
|
357
|
-
request.env['rack.attack.throttle_data'][name] # => { :count
|
371
|
+
request.env['rack.attack.throttle_data'][name] # => { discriminator: d, count: n, period: p, limit: l, epoch_time: t }
|
358
372
|
```
|
359
373
|
|
360
374
|
## Logging & Instrumentation
|
data/lib/rack/attack.rb
CHANGED
@@ -2,163 +2,114 @@
|
|
2
2
|
|
3
3
|
require 'rack'
|
4
4
|
require 'forwardable'
|
5
|
+
require 'rack/attack/cache'
|
6
|
+
require 'rack/attack/configuration'
|
5
7
|
require 'rack/attack/path_normalizer'
|
6
8
|
require 'rack/attack/request'
|
7
|
-
require "ipaddr"
|
8
|
-
|
9
|
-
class Rack::Attack
|
10
|
-
class MisconfiguredStoreError < StandardError; end
|
11
|
-
class MissingStoreError < StandardError; end
|
12
|
-
|
13
|
-
autoload :Cache, 'rack/attack/cache'
|
14
|
-
autoload :Check, 'rack/attack/check'
|
15
|
-
autoload :Throttle, 'rack/attack/throttle'
|
16
|
-
autoload :Safelist, 'rack/attack/safelist'
|
17
|
-
autoload :Blocklist, 'rack/attack/blocklist'
|
18
|
-
autoload :Track, 'rack/attack/track'
|
19
|
-
autoload :StoreProxy, 'rack/attack/store_proxy'
|
20
|
-
autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
|
21
|
-
autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy'
|
22
|
-
autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
|
23
|
-
autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
|
24
|
-
autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
|
25
|
-
autoload :ActiveSupportRedisStoreProxy, 'rack/attack/store_proxy/active_support_redis_store_proxy'
|
26
|
-
autoload :Fail2Ban, 'rack/attack/fail2ban'
|
27
|
-
autoload :Allow2Ban, 'rack/attack/allow2ban'
|
28
|
-
|
29
|
-
class << self
|
30
|
-
attr_accessor :notifier, :blocklisted_response, :throttled_response, :anonymous_blocklists, :anonymous_safelists
|
31
|
-
|
32
|
-
def safelist(name = nil, &block)
|
33
|
-
safelist = Safelist.new(name, &block)
|
34
|
-
|
35
|
-
if name
|
36
|
-
safelists[name] = safelist
|
37
|
-
else
|
38
|
-
anonymous_safelists << safelist
|
39
|
-
end
|
40
|
-
end
|
41
9
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
10
|
+
require 'rack/attack/railtie' if defined?(::Rails)
|
11
|
+
|
12
|
+
module Rack
|
13
|
+
class Attack
|
14
|
+
class Error < StandardError; end
|
15
|
+
class MisconfiguredStoreError < Error; end
|
16
|
+
class MissingStoreError < Error; end
|
17
|
+
class IncompatibleStoreError < Error; end
|
18
|
+
|
19
|
+
autoload :Check, 'rack/attack/check'
|
20
|
+
autoload :Throttle, 'rack/attack/throttle'
|
21
|
+
autoload :Safelist, 'rack/attack/safelist'
|
22
|
+
autoload :Blocklist, 'rack/attack/blocklist'
|
23
|
+
autoload :Track, 'rack/attack/track'
|
24
|
+
autoload :StoreProxy, 'rack/attack/store_proxy'
|
25
|
+
autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
|
26
|
+
autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy'
|
27
|
+
autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
|
28
|
+
autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
|
29
|
+
autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
|
30
|
+
autoload :ActiveSupportRedisStoreProxy, 'rack/attack/store_proxy/active_support_redis_store_proxy'
|
31
|
+
autoload :Fail2Ban, 'rack/attack/fail2ban'
|
32
|
+
autoload :Allow2Ban, 'rack/attack/allow2ban'
|
33
|
+
|
34
|
+
class << self
|
35
|
+
attr_accessor :enabled, :notifier
|
36
|
+
attr_reader :configuration
|
37
|
+
|
38
|
+
def instrument(request)
|
39
|
+
if notifier
|
40
|
+
event_type = request.env["rack.attack.match_type"]
|
41
|
+
notifier.instrument("#{event_type}.rack_attack", request: request)
|
42
|
+
|
43
|
+
# Deprecated: Keeping just for backwards compatibility
|
44
|
+
notifier.instrument("rack.attack", request: request)
|
45
|
+
end
|
49
46
|
end
|
50
|
-
end
|
51
|
-
|
52
|
-
def blocklist_ip(ip_address)
|
53
|
-
anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
54
|
-
end
|
55
|
-
|
56
|
-
def safelist_ip(ip_address)
|
57
|
-
anonymous_safelists << Safelist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
58
|
-
end
|
59
|
-
|
60
|
-
def throttle(name, options, &block)
|
61
|
-
throttles[name] = Throttle.new(name, options, &block)
|
62
|
-
end
|
63
|
-
|
64
|
-
def track(name, options = {}, &block)
|
65
|
-
tracks[name] = Track.new(name, options, &block)
|
66
|
-
end
|
67
|
-
|
68
|
-
def safelists
|
69
|
-
@safelists ||= {}
|
70
|
-
end
|
71
|
-
|
72
|
-
def blocklists
|
73
|
-
@blocklists ||= {}
|
74
|
-
end
|
75
|
-
|
76
|
-
def throttles
|
77
|
-
@throttles ||= {}
|
78
|
-
end
|
79
|
-
|
80
|
-
def tracks
|
81
|
-
@tracks ||= {}
|
82
|
-
end
|
83
|
-
|
84
|
-
def safelisted?(request)
|
85
|
-
anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
86
|
-
safelists.any? { |_name, safelist| safelist.matched_by?(request) }
|
87
|
-
end
|
88
47
|
|
89
|
-
|
90
|
-
|
91
|
-
blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
|
92
|
-
end
|
93
|
-
|
94
|
-
def throttled?(request)
|
95
|
-
throttles.any? do |_name, throttle|
|
96
|
-
throttle.matched_by?(request)
|
48
|
+
def cache
|
49
|
+
@cache ||= Cache.new
|
97
50
|
end
|
98
|
-
end
|
99
51
|
|
100
|
-
|
101
|
-
|
102
|
-
|
52
|
+
def clear!
|
53
|
+
warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
|
54
|
+
@configuration.clear_configuration
|
103
55
|
end
|
104
|
-
end
|
105
|
-
|
106
|
-
def instrument(request)
|
107
|
-
if notifier
|
108
|
-
event_type = request.env["rack.attack.match_type"]
|
109
|
-
notifier.instrument("#{event_type}.rack_attack", request: request)
|
110
56
|
|
111
|
-
|
112
|
-
|
57
|
+
def reset!
|
58
|
+
cache.reset!
|
113
59
|
end
|
114
|
-
end
|
115
|
-
|
116
|
-
def cache
|
117
|
-
@cache ||= Cache.new
|
118
|
-
end
|
119
60
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
@app
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
61
|
+
extend Forwardable
|
62
|
+
def_delegators(
|
63
|
+
:@configuration,
|
64
|
+
:safelist,
|
65
|
+
:blocklist,
|
66
|
+
:blocklist_ip,
|
67
|
+
:safelist_ip,
|
68
|
+
:throttle,
|
69
|
+
:track,
|
70
|
+
:blocklisted_response,
|
71
|
+
:blocklisted_response=,
|
72
|
+
:throttled_response,
|
73
|
+
:throttled_response=,
|
74
|
+
:throttled_response_retry_after_header,
|
75
|
+
:throttled_response_retry_after_header=,
|
76
|
+
:clear_configuration,
|
77
|
+
:safelists,
|
78
|
+
:blocklists,
|
79
|
+
:throttles,
|
80
|
+
:tracks
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Set defaults
|
85
|
+
@enabled = true
|
86
|
+
@notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
|
87
|
+
@configuration = Configuration.new
|
88
|
+
|
89
|
+
attr_reader :configuration
|
90
|
+
|
91
|
+
def initialize(app)
|
92
|
+
@app = app
|
93
|
+
@configuration = self.class.configuration
|
94
|
+
end
|
95
|
+
|
96
|
+
def call(env)
|
97
|
+
return @app.call(env) if !self.class.enabled || env["rack.attack.called"]
|
98
|
+
|
99
|
+
env["rack.attack.called"] = true
|
100
|
+
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
|
101
|
+
request = Rack::Attack::Request.new(env)
|
102
|
+
|
103
|
+
if configuration.safelisted?(request)
|
104
|
+
@app.call(env)
|
105
|
+
elsif configuration.blocklisted?(request)
|
106
|
+
configuration.blocklisted_response.call(env)
|
107
|
+
elsif configuration.throttled?(request)
|
108
|
+
configuration.throttled_response.call(env)
|
109
|
+
else
|
110
|
+
configuration.tracked?(request)
|
111
|
+
@app.call(env)
|
112
|
+
end
|
159
113
|
end
|
160
114
|
end
|
161
|
-
|
162
|
-
extend Forwardable
|
163
|
-
def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked?
|
164
115
|
end
|
data/lib/rack/attack/cache.rb
CHANGED
@@ -41,6 +41,17 @@ module Rack
|
|
41
41
|
store.delete("#{prefix}:#{unprefixed_key}")
|
42
42
|
end
|
43
43
|
|
44
|
+
def reset!
|
45
|
+
if store.respond_to?(:delete_matched)
|
46
|
+
store.delete_matched("#{prefix}*")
|
47
|
+
else
|
48
|
+
raise(
|
49
|
+
Rack::Attack::IncompatibleStoreError,
|
50
|
+
"Configured store #{store.class.name} doesn't respond to #delete_matched method"
|
51
|
+
)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
44
55
|
private
|
45
56
|
|
46
57
|
def key_and_expiry(unprefixed_key, period)
|
@@ -73,7 +84,10 @@ module Rack
|
|
73
84
|
|
74
85
|
def enforce_store_method_presence!(method_name)
|
75
86
|
if !store.respond_to?(method_name)
|
76
|
-
raise
|
87
|
+
raise(
|
88
|
+
Rack::Attack::MisconfiguredStoreError,
|
89
|
+
"Configured store #{store.class.name} doesn't respond to ##{method_name} method"
|
90
|
+
)
|
77
91
|
end
|
78
92
|
end
|
79
93
|
end
|
data/lib/rack/attack/check.rb
CHANGED
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ipaddr"
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class Attack
|
7
|
+
class Configuration
|
8
|
+
DEFAULT_BLOCKLISTED_RESPONSE = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] }
|
9
|
+
|
10
|
+
DEFAULT_THROTTLED_RESPONSE = lambda do |env|
|
11
|
+
if Rack::Attack.configuration.throttled_response_retry_after_header
|
12
|
+
match_data = env['rack.attack.match_data']
|
13
|
+
now = match_data[:epoch_time]
|
14
|
+
retry_after = match_data[:period] - (now % match_data[:period])
|
15
|
+
|
16
|
+
[429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]]
|
17
|
+
else
|
18
|
+
[429, { 'Content-Type' => 'text/plain' }, ["Retry later\n"]]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :safelists, :blocklists, :throttles, :anonymous_blocklists, :anonymous_safelists
|
23
|
+
attr_accessor :blocklisted_response, :throttled_response, :throttled_response_retry_after_header
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
set_defaults
|
27
|
+
end
|
28
|
+
|
29
|
+
def safelist(name = nil, &block)
|
30
|
+
safelist = Safelist.new(name, &block)
|
31
|
+
|
32
|
+
if name
|
33
|
+
@safelists[name] = safelist
|
34
|
+
else
|
35
|
+
@anonymous_safelists << safelist
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def blocklist(name = nil, &block)
|
40
|
+
blocklist = Blocklist.new(name, &block)
|
41
|
+
|
42
|
+
if name
|
43
|
+
@blocklists[name] = blocklist
|
44
|
+
else
|
45
|
+
@anonymous_blocklists << blocklist
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def blocklist_ip(ip_address)
|
50
|
+
@anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
51
|
+
end
|
52
|
+
|
53
|
+
def safelist_ip(ip_address)
|
54
|
+
@anonymous_safelists << Safelist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
55
|
+
end
|
56
|
+
|
57
|
+
def throttle(name, options, &block)
|
58
|
+
@throttles[name] = Throttle.new(name, options, &block)
|
59
|
+
end
|
60
|
+
|
61
|
+
def track(name, options = {}, &block)
|
62
|
+
@tracks[name] = Track.new(name, options, &block)
|
63
|
+
end
|
64
|
+
|
65
|
+
def safelisted?(request)
|
66
|
+
@anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
67
|
+
@safelists.any? { |_name, safelist| safelist.matched_by?(request) }
|
68
|
+
end
|
69
|
+
|
70
|
+
def blocklisted?(request)
|
71
|
+
@anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
|
72
|
+
@blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
|
73
|
+
end
|
74
|
+
|
75
|
+
def throttled?(request)
|
76
|
+
@throttles.any? do |_name, throttle|
|
77
|
+
throttle.matched_by?(request)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def tracked?(request)
|
82
|
+
@tracks.each_value do |track|
|
83
|
+
track.matched_by?(request)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def clear_configuration
|
88
|
+
set_defaults
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def set_defaults
|
94
|
+
@safelists = {}
|
95
|
+
@blocklists = {}
|
96
|
+
@throttles = {}
|
97
|
+
@tracks = {}
|
98
|
+
@anonymous_blocklists = []
|
99
|
+
@anonymous_safelists = []
|
100
|
+
@throttled_response_retry_after_header = false
|
101
|
+
|
102
|
+
@blocklisted_response = DEFAULT_BLOCKLISTED_RESPONSE
|
103
|
+
@throttled_response = DEFAULT_THROTTLED_RESPONSE
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|