rack-attack 6.2.0 → 6.4.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 +28 -14
- data/lib/rack/attack.rb +45 -106
- data/lib/rack/attack/cache.rb +12 -0
- data/lib/rack/attack/check.rb +1 -0
- data/lib/rack/attack/configuration.rb +107 -0
- data/lib/rack/attack/railtie.rb +2 -10
- data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +9 -21
- data/lib/rack/attack/store_proxy/redis_proxy.rb +16 -7
- data/lib/rack/attack/throttle.rb +29 -12
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/rails_middleware_spec.rb +0 -6
- data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +1 -1
- data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +1 -1
- data/spec/acceptance/throttling_spec.rb +19 -1
- data/spec/integration/offline_spec.rb +46 -1
- data/spec/rack_attack_spec.rb +22 -0
- data/spec/rack_attack_throttle_spec.rb +0 -4
- data/spec/spec_helper.rb +2 -5
- metadata +38 -38
- data/bin/setup +0 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e7d44de650fae1c83d5a3da49dc8f304e44280f72bd209d3f78643b90d573bd8
|
4
|
+
data.tar.gz: a39d0270489617a8c0a49e01868c24cc87311e80457fbc104c86e45d29978f51
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7d9d965cc672bba8ab2b9f333746e32091363d6b65bf290104c248799a811f272ad8388e7f7b3d870d382e9c80a1003a300f9380c2d8082195972817146a281d
|
7
|
+
data.tar.gz: fbfa381116824ea4de492b66408d15bd708692a74275548c9b167868c0bee566f79a216046213c43a8eb3117b869e86ac99f989e5e4c045c30267db2981b2c6b
|
data/README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
__Note__: You are viewing the development version README.
|
2
|
-
For the README consistent with the latest released version see https://github.com/
|
2
|
+
For the README consistent with the latest released version see https://github.com/rack/rack-attack/blob/6-stable/README.md.
|
3
3
|
|
4
4
|
# Rack::Attack
|
5
5
|
|
@@ -10,7 +10,7 @@ Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily
|
|
10
10
|
See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack.
|
11
11
|
|
12
12
|
[](https://badge.fury.io/rb/rack-attack)
|
13
|
-
[](https://travis-ci.org/rack/rack-attack)
|
14
14
|
[](https://codeclimate.com/github/kickstarter/rack-attack)
|
15
15
|
[](https://gitter.im/rack-attack/rack-attack)
|
16
16
|
|
@@ -37,9 +37,9 @@ See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-ha
|
|
37
37
|
- [Customizing responses](#customizing-responses)
|
38
38
|
- [RateLimit headers for well-behaved clients](#ratelimit-headers-for-well-behaved-clients)
|
39
39
|
- [Logging & Instrumentation](#logging--instrumentation)
|
40
|
+
- [Testing](#testing)
|
40
41
|
- [How it works](#how-it-works)
|
41
42
|
- [About Tracks](#about-tracks)
|
42
|
-
- [Testing](#testing)
|
43
43
|
- [Performance](#performance)
|
44
44
|
- [Motivation](#motivation)
|
45
45
|
- [Contributing](#contributing)
|
@@ -140,7 +140,7 @@ E.g.
|
|
140
140
|
# Provided that trusted users use an HTTP request header named APIKey
|
141
141
|
Rack::Attack.safelist("mark any authenticated access safe") do |request|
|
142
142
|
# Requests are allowed if the return value is truthy
|
143
|
-
request.env["
|
143
|
+
request.env["HTTP_APIKEY"] == "secret-string"
|
144
144
|
end
|
145
145
|
|
146
146
|
# Always allow requests from localhost
|
@@ -263,10 +263,12 @@ Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request|
|
|
263
263
|
end
|
264
264
|
|
265
265
|
# Throttle login attempts for a given email parameter to 6 reqs/minute
|
266
|
-
# Return the email as a discriminator on POST /login requests
|
266
|
+
# Return the *normalized* email as a discriminator on POST /login requests
|
267
267
|
Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req|
|
268
268
|
if req.path == '/login' && req.post?
|
269
|
-
|
269
|
+
# Normalize the email, using the same logic as your authentication process, to
|
270
|
+
# protect against rate limit bypasses.
|
271
|
+
req.params['email'].to_s.downcase.gsub(/\s+/, "")
|
270
272
|
end
|
271
273
|
end
|
272
274
|
|
@@ -342,6 +344,11 @@ end
|
|
342
344
|
While Rack::Attack's primary focus is minimizing harm from abusive clients, it
|
343
345
|
can also be used to return rate limit data that's helpful for well-behaved clients.
|
344
346
|
|
347
|
+
If you want to return to user how many seconds to wait until they can start sending requests again, this can be done through enabling `Retry-After` header:
|
348
|
+
```ruby
|
349
|
+
Rack::Attack.throttled_response_retry_after_header = true
|
350
|
+
```
|
351
|
+
|
345
352
|
Here's an example response that includes conventional `RateLimit-*` headers:
|
346
353
|
|
347
354
|
```ruby
|
@@ -372,7 +379,7 @@ Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/
|
|
372
379
|
|
373
380
|
You can subscribe to `rack_attack` events and log it, graph it, etc.
|
374
381
|
|
375
|
-
To get notified about specific type of events, subscribe to the event name followed by the `rack_attack`
|
382
|
+
To get notified about specific type of events, subscribe to the event name followed by the `rack_attack` namespace.
|
376
383
|
E.g. for throttles use:
|
377
384
|
|
378
385
|
```ruby
|
@@ -393,6 +400,20 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, r
|
|
393
400
|
end
|
394
401
|
```
|
395
402
|
|
403
|
+
## Testing
|
404
|
+
|
405
|
+
A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will
|
406
|
+
need to enable the cache in your development environment. See [Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html)
|
407
|
+
for more on how to do this.
|
408
|
+
|
409
|
+
### Disabling
|
410
|
+
|
411
|
+
`Rack::Attack.enabled = false` can be used to either completely disable Rack::Attack in your tests, or to disable/enable for specific test cases only.
|
412
|
+
|
413
|
+
### Test case isolation
|
414
|
+
|
415
|
+
`Rack::Attack.reset!` can be used in your test suite to clear any Rack::Attack state between different test cases.
|
416
|
+
|
396
417
|
## How it works
|
397
418
|
|
398
419
|
The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default.
|
@@ -429,13 +450,6 @@ can cleanly monkey patch helper methods onto the
|
|
429
450
|
|
430
451
|
`Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes.
|
431
452
|
|
432
|
-
|
433
|
-
## Testing
|
434
|
-
|
435
|
-
A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will
|
436
|
-
need to enable the cache in your development environment. See [Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html)
|
437
|
-
for more on how to do this.
|
438
|
-
|
439
453
|
## Performance
|
440
454
|
|
441
455
|
The overhead of running Rack::Attack is typically negligible (a few milliseconds per request),
|
data/lib/rack/attack.rb
CHANGED
@@ -2,9 +2,10 @@
|
|
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
|
|
9
10
|
require 'rack/attack/railtie' if defined?(::Rails)
|
10
11
|
|
@@ -13,8 +14,8 @@ module Rack
|
|
13
14
|
class Error < StandardError; end
|
14
15
|
class MisconfiguredStoreError < Error; end
|
15
16
|
class MissingStoreError < Error; end
|
17
|
+
class IncompatibleStoreError < Error; end
|
16
18
|
|
17
|
-
autoload :Cache, 'rack/attack/cache'
|
18
19
|
autoload :Check, 'rack/attack/check'
|
19
20
|
autoload :Throttle, 'rack/attack/throttle'
|
20
21
|
autoload :Safelist, 'rack/attack/safelist'
|
@@ -31,82 +32,8 @@ module Rack
|
|
31
32
|
autoload :Allow2Ban, 'rack/attack/allow2ban'
|
32
33
|
|
33
34
|
class << self
|
34
|
-
attr_accessor :enabled, :notifier
|
35
|
-
|
36
|
-
|
37
|
-
def safelist(name = nil, &block)
|
38
|
-
safelist = Safelist.new(name, &block)
|
39
|
-
|
40
|
-
if name
|
41
|
-
safelists[name] = safelist
|
42
|
-
else
|
43
|
-
anonymous_safelists << safelist
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
def blocklist(name = nil, &block)
|
48
|
-
blocklist = Blocklist.new(name, &block)
|
49
|
-
|
50
|
-
if name
|
51
|
-
blocklists[name] = blocklist
|
52
|
-
else
|
53
|
-
anonymous_blocklists << blocklist
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
def blocklist_ip(ip_address)
|
58
|
-
anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
59
|
-
end
|
60
|
-
|
61
|
-
def safelist_ip(ip_address)
|
62
|
-
anonymous_safelists << Safelist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
63
|
-
end
|
64
|
-
|
65
|
-
def throttle(name, options, &block)
|
66
|
-
throttles[name] = Throttle.new(name, options, &block)
|
67
|
-
end
|
68
|
-
|
69
|
-
def track(name, options = {}, &block)
|
70
|
-
tracks[name] = Track.new(name, options, &block)
|
71
|
-
end
|
72
|
-
|
73
|
-
def safelists
|
74
|
-
@safelists ||= {}
|
75
|
-
end
|
76
|
-
|
77
|
-
def blocklists
|
78
|
-
@blocklists ||= {}
|
79
|
-
end
|
80
|
-
|
81
|
-
def throttles
|
82
|
-
@throttles ||= {}
|
83
|
-
end
|
84
|
-
|
85
|
-
def tracks
|
86
|
-
@tracks ||= {}
|
87
|
-
end
|
88
|
-
|
89
|
-
def safelisted?(request)
|
90
|
-
anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
91
|
-
safelists.any? { |_name, safelist| safelist.matched_by?(request) }
|
92
|
-
end
|
93
|
-
|
94
|
-
def blocklisted?(request)
|
95
|
-
anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
|
96
|
-
blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
|
97
|
-
end
|
98
|
-
|
99
|
-
def throttled?(request)
|
100
|
-
throttles.any? do |_name, throttle|
|
101
|
-
throttle.matched_by?(request)
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
def tracked?(request)
|
106
|
-
tracks.each_value do |track|
|
107
|
-
track.matched_by?(request)
|
108
|
-
end
|
109
|
-
end
|
35
|
+
attr_accessor :enabled, :notifier
|
36
|
+
attr_reader :configuration
|
110
37
|
|
111
38
|
def instrument(request)
|
112
39
|
if notifier
|
@@ -122,55 +49,67 @@ module Rack
|
|
122
49
|
@cache ||= Cache.new
|
123
50
|
end
|
124
51
|
|
125
|
-
def clear_configuration
|
126
|
-
@safelists = {}
|
127
|
-
@blocklists = {}
|
128
|
-
@throttles = {}
|
129
|
-
@tracks = {}
|
130
|
-
self.anonymous_blocklists = []
|
131
|
-
self.anonymous_safelists = []
|
132
|
-
end
|
133
|
-
|
134
52
|
def clear!
|
135
53
|
warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
|
136
|
-
clear_configuration
|
137
|
-
end
|
54
|
+
@configuration.clear_configuration
|
55
|
+
end
|
56
|
+
|
57
|
+
def reset!
|
58
|
+
cache.reset!
|
59
|
+
end
|
60
|
+
|
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
|
+
)
|
138
82
|
end
|
139
83
|
|
140
84
|
# Set defaults
|
141
85
|
@enabled = true
|
142
|
-
@anonymous_blocklists = []
|
143
|
-
@anonymous_safelists = []
|
144
86
|
@notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
|
145
|
-
@
|
146
|
-
|
147
|
-
|
148
|
-
[429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]]
|
149
|
-
end
|
87
|
+
@configuration = Configuration.new
|
88
|
+
|
89
|
+
attr_reader :configuration
|
150
90
|
|
151
91
|
def initialize(app)
|
152
92
|
@app = app
|
93
|
+
@configuration = self.class.configuration
|
153
94
|
end
|
154
95
|
|
155
96
|
def call(env)
|
156
|
-
return @app.call(env)
|
97
|
+
return @app.call(env) if !self.class.enabled || env["rack.attack.called"]
|
157
98
|
|
99
|
+
env["rack.attack.called"] = true
|
158
100
|
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
|
159
101
|
request = Rack::Attack::Request.new(env)
|
160
102
|
|
161
|
-
if safelisted?(request)
|
103
|
+
if configuration.safelisted?(request)
|
162
104
|
@app.call(env)
|
163
|
-
elsif blocklisted?(request)
|
164
|
-
|
165
|
-
elsif throttled?(request)
|
166
|
-
|
105
|
+
elsif configuration.blocklisted?(request)
|
106
|
+
configuration.blocklisted_response.call(env)
|
107
|
+
elsif configuration.throttled?(request)
|
108
|
+
configuration.throttled_response.call(env)
|
167
109
|
else
|
168
|
-
tracked?(request)
|
110
|
+
configuration.tracked?(request)
|
169
111
|
@app.call(env)
|
170
112
|
end
|
171
113
|
end
|
172
|
-
|
173
|
-
extend Forwardable
|
174
|
-
def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked?
|
175
114
|
end
|
176
115
|
end
|
data/lib/rack/attack/cache.rb
CHANGED
@@ -12,6 +12,7 @@ module Rack
|
|
12
12
|
end
|
13
13
|
|
14
14
|
attr_reader :store
|
15
|
+
|
15
16
|
def store=(store)
|
16
17
|
@store = StoreProxy.build(store)
|
17
18
|
end
|
@@ -41,6 +42,17 @@ module Rack
|
|
41
42
|
store.delete("#{prefix}:#{unprefixed_key}")
|
42
43
|
end
|
43
44
|
|
45
|
+
def reset!
|
46
|
+
if store.respond_to?(:delete_matched)
|
47
|
+
store.delete_matched("#{prefix}*")
|
48
|
+
else
|
49
|
+
raise(
|
50
|
+
Rack::Attack::IncompatibleStoreError,
|
51
|
+
"Configured store #{store.class.name} doesn't respond to #delete_matched method"
|
52
|
+
)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
44
56
|
private
|
45
57
|
|
46
58
|
def key_and_expiry(unprefixed_key, period)
|
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
|
data/lib/rack/attack/railtie.rb
CHANGED
@@ -3,17 +3,9 @@
|
|
3
3
|
module Rack
|
4
4
|
class Attack
|
5
5
|
class Railtie < ::Rails::Railtie
|
6
|
-
initializer
|
6
|
+
initializer "rack-attack.middleware" do |app|
|
7
7
|
if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("5.1")
|
8
|
-
|
9
|
-
operations = middlewares.send(:operations) + middlewares.send(:delete_operations)
|
10
|
-
|
11
|
-
use_middleware = operations.none? do |operation|
|
12
|
-
middleware = operation[1]
|
13
|
-
middleware.include?(Rack::Attack)
|
14
|
-
end
|
15
|
-
|
16
|
-
middlewares.use(Rack::Attack) if use_middleware
|
8
|
+
app.middleware.use(Rack::Attack)
|
17
9
|
end
|
18
10
|
end
|
19
11
|
end
|
@@ -10,38 +10,26 @@ module Rack
|
|
10
10
|
store.class.name == "ActiveSupport::Cache::RedisCacheStore"
|
11
11
|
end
|
12
12
|
|
13
|
-
def increment(name, amount = 1, options
|
13
|
+
def increment(name, amount = 1, **options)
|
14
14
|
# RedisCacheStore#increment ignores options[:expires_in].
|
15
15
|
#
|
16
16
|
# So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize
|
17
17
|
# the counter. After that we continue using the original RedisCacheStore#increment.
|
18
|
-
|
19
|
-
|
20
|
-
write(name, amount, options)
|
18
|
+
if options[:expires_in] && !read(name)
|
19
|
+
write(name, amount, options)
|
21
20
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
end
|
21
|
+
amount
|
22
|
+
else
|
23
|
+
super
|
26
24
|
end
|
27
25
|
end
|
28
26
|
|
29
|
-
def read(
|
30
|
-
|
27
|
+
def read(name, options = {})
|
28
|
+
super(name, options.merge!(raw: true))
|
31
29
|
end
|
32
30
|
|
33
31
|
def write(name, value, options = {})
|
34
|
-
|
35
|
-
super(name, value, options.merge!(raw: true))
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
private
|
40
|
-
|
41
|
-
def rescuing
|
42
|
-
yield
|
43
|
-
rescue Redis::BaseError
|
44
|
-
nil
|
32
|
+
super(name, value, options.merge!(raw: true))
|
45
33
|
end
|
46
34
|
end
|
47
35
|
end
|
@@ -31,27 +31,36 @@ module Rack
|
|
31
31
|
end
|
32
32
|
|
33
33
|
def increment(key, amount, options = {})
|
34
|
-
count = nil
|
35
|
-
|
36
34
|
rescuing do
|
37
35
|
pipelined do
|
38
|
-
|
36
|
+
incrby(key, amount)
|
39
37
|
expire(key, options[:expires_in]) if options[:expires_in]
|
40
|
-
end
|
38
|
+
end.first
|
41
39
|
end
|
42
|
-
|
43
|
-
count.value if count
|
44
40
|
end
|
45
41
|
|
46
42
|
def delete(key, _options = {})
|
47
43
|
rescuing { del(key) }
|
48
44
|
end
|
49
45
|
|
46
|
+
def delete_matched(matcher, _options = nil)
|
47
|
+
cursor = "0"
|
48
|
+
|
49
|
+
rescuing do
|
50
|
+
# Fetch keys in batches using SCAN to avoid blocking the Redis server.
|
51
|
+
loop do
|
52
|
+
cursor, keys = scan(cursor, match: matcher, count: 1000)
|
53
|
+
del(*keys) unless keys.empty?
|
54
|
+
break if cursor == "0"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
50
59
|
private
|
51
60
|
|
52
61
|
def rescuing
|
53
62
|
yield
|
54
|
-
rescue Redis::
|
63
|
+
rescue Redis::BaseConnectionError
|
55
64
|
nil
|
56
65
|
end
|
57
66
|
end
|
data/lib/rack/attack/throttle.rb
CHANGED
@@ -6,6 +6,7 @@ module Rack
|
|
6
6
|
MANDATORY_OPTIONS = [:limit, :period].freeze
|
7
7
|
|
8
8
|
attr_reader :name, :limit, :period, :block, :type
|
9
|
+
|
9
10
|
def initialize(name, options, &block)
|
10
11
|
@name = name
|
11
12
|
@block = block
|
@@ -23,34 +24,50 @@ module Rack
|
|
23
24
|
|
24
25
|
def matched_by?(request)
|
25
26
|
discriminator = block.call(request)
|
27
|
+
|
26
28
|
return false unless discriminator
|
27
29
|
|
28
|
-
current_period
|
29
|
-
current_limit
|
30
|
-
|
31
|
-
count = cache.count(key, current_period)
|
32
|
-
epoch_time = cache.last_epoch_time
|
30
|
+
current_period = period_for(request)
|
31
|
+
current_limit = limit_for(request)
|
32
|
+
count = cache.count("#{name}:#{discriminator}", current_period)
|
33
33
|
|
34
34
|
data = {
|
35
35
|
discriminator: discriminator,
|
36
36
|
count: count,
|
37
37
|
period: current_period,
|
38
38
|
limit: current_limit,
|
39
|
-
epoch_time:
|
39
|
+
epoch_time: cache.last_epoch_time
|
40
40
|
}
|
41
41
|
|
42
|
-
(request.env['rack.attack.throttle_data'] ||= {})[name] = data
|
43
|
-
|
44
42
|
(count > current_limit).tap do |throttled|
|
43
|
+
annotate_request_with_throttle_data(request, data)
|
45
44
|
if throttled
|
46
|
-
request
|
47
|
-
request.env['rack.attack.match_discriminator'] = discriminator
|
48
|
-
request.env['rack.attack.match_type'] = type
|
49
|
-
request.env['rack.attack.match_data'] = data
|
45
|
+
annotate_request_with_matched_data(request, data)
|
50
46
|
Rack::Attack.instrument(request)
|
51
47
|
end
|
52
48
|
end
|
53
49
|
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def period_for(request)
|
54
|
+
period.respond_to?(:call) ? period.call(request) : period
|
55
|
+
end
|
56
|
+
|
57
|
+
def limit_for(request)
|
58
|
+
limit.respond_to?(:call) ? limit.call(request) : limit
|
59
|
+
end
|
60
|
+
|
61
|
+
def annotate_request_with_throttle_data(request, data)
|
62
|
+
(request.env['rack.attack.throttle_data'] ||= {})[name] = data
|
63
|
+
end
|
64
|
+
|
65
|
+
def annotate_request_with_matched_data(request, data)
|
66
|
+
request.env['rack.attack.matched'] = name
|
67
|
+
request.env['rack.attack.match_discriminator'] = data[:discriminator]
|
68
|
+
request.env['rack.attack.match_type'] = type
|
69
|
+
request.env['rack.attack.match_data'] = data
|
70
|
+
end
|
54
71
|
end
|
55
72
|
end
|
56
73
|
end
|
data/lib/rack/attack/version.rb
CHANGED
@@ -18,12 +18,6 @@ if defined?(Rails)
|
|
18
18
|
assert_equal 1, @app.middleware.count(Rack::Attack)
|
19
19
|
end
|
20
20
|
|
21
|
-
it "is not added when it was added explicitly" do
|
22
|
-
@app.config.middleware.use(Rack::Attack)
|
23
|
-
@app.initialize!
|
24
|
-
assert_equal 1, @app.middleware.count(Rack::Attack)
|
25
|
-
end
|
26
|
-
|
27
21
|
it "is not added when it was explicitly deleted" do
|
28
22
|
@app.config.middleware.delete(Rack::Attack)
|
29
23
|
@app.initialize!
|
@@ -21,6 +21,6 @@ if should_run
|
|
21
21
|
Rack::Attack.cache.store.clear
|
22
22
|
end
|
23
23
|
|
24
|
-
it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.
|
24
|
+
it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) })
|
25
25
|
end
|
26
26
|
end
|
@@ -20,6 +20,6 @@ if should_run
|
|
20
20
|
Rack::Attack.cache.store.clear
|
21
21
|
end
|
22
22
|
|
23
|
-
it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.
|
23
|
+
it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) })
|
24
24
|
end
|
25
25
|
end
|
@@ -20,7 +20,7 @@ describe "#throttle" do
|
|
20
20
|
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
21
21
|
|
22
22
|
assert_equal 429, last_response.status
|
23
|
-
|
23
|
+
assert_nil last_response.headers["Retry-After"]
|
24
24
|
assert_equal "Retry later\n", last_response.body
|
25
25
|
|
26
26
|
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
@@ -34,6 +34,24 @@ describe "#throttle" do
|
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
|
+
it "returns correct Retry-After header if enabled" do
|
38
|
+
Rack::Attack.throttled_response_retry_after_header = true
|
39
|
+
|
40
|
+
Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request|
|
41
|
+
request.ip
|
42
|
+
end
|
43
|
+
|
44
|
+
Timecop.freeze(Time.at(0)) do
|
45
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
46
|
+
assert_equal 200, last_response.status
|
47
|
+
end
|
48
|
+
|
49
|
+
Timecop.freeze(Time.at(25)) do
|
50
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
51
|
+
assert_equal "35", last_response.headers["Retry-After"]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
37
55
|
it "supports limit to be dynamic" do
|
38
56
|
# Could be used to have different rate limits for authorized
|
39
57
|
# vs general requests
|
@@ -13,7 +13,11 @@ OfflineExamples = Minitest::SharedExamples.new do
|
|
13
13
|
end
|
14
14
|
|
15
15
|
it 'should count' do
|
16
|
-
@cache.
|
16
|
+
@cache.count('cache-test-key', 1)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'should delete' do
|
20
|
+
@cache.delete('cache-test-key')
|
17
21
|
end
|
18
22
|
end
|
19
23
|
|
@@ -29,6 +33,18 @@ if defined?(::ActiveSupport::Cache::RedisStore)
|
|
29
33
|
end
|
30
34
|
end
|
31
35
|
|
36
|
+
if defined?(Redis) && defined?(ActiveSupport::Cache::RedisCacheStore) && Redis::VERSION >= '4'
|
37
|
+
describe 'when Redis is offline' do
|
38
|
+
include OfflineExamples
|
39
|
+
|
40
|
+
before do
|
41
|
+
@cache = Rack::Attack::Cache.new
|
42
|
+
# Use presumably unused port for Redis client
|
43
|
+
@cache.store = ActiveSupport::Cache::RedisCacheStore.new(host: '127.0.0.1', port: 3333)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
32
48
|
if defined?(::Dalli)
|
33
49
|
describe 'when Memcached is offline' do
|
34
50
|
include OfflineExamples
|
@@ -45,3 +61,32 @@ if defined?(::Dalli)
|
|
45
61
|
end
|
46
62
|
end
|
47
63
|
end
|
64
|
+
|
65
|
+
if defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore)
|
66
|
+
describe 'when Memcached is offline' do
|
67
|
+
include OfflineExamples
|
68
|
+
|
69
|
+
before do
|
70
|
+
Dalli.logger.level = Logger::FATAL
|
71
|
+
|
72
|
+
@cache = Rack::Attack::Cache.new
|
73
|
+
@cache.store = ActiveSupport::Cache::MemCacheStore.new('127.0.0.1:22122')
|
74
|
+
end
|
75
|
+
|
76
|
+
after do
|
77
|
+
Dalli.logger.level = Logger::INFO
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
if defined?(Redis)
|
83
|
+
describe 'when Redis is offline' do
|
84
|
+
include OfflineExamples
|
85
|
+
|
86
|
+
before do
|
87
|
+
@cache = Rack::Attack::Cache.new
|
88
|
+
# Use presumably unused port for Redis client
|
89
|
+
@cache.store = Redis.new(host: '127.0.0.1', port: 3333)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/spec/rack_attack_spec.rb
CHANGED
@@ -99,4 +99,26 @@ describe 'Rack::Attack' do
|
|
99
99
|
end
|
100
100
|
end
|
101
101
|
end
|
102
|
+
|
103
|
+
describe 'reset!' do
|
104
|
+
it 'raises an error when is not supported by cache store' do
|
105
|
+
Rack::Attack.cache.store = Class.new
|
106
|
+
assert_raises(Rack::Attack::IncompatibleStoreError) do
|
107
|
+
Rack::Attack.reset!
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
if defined?(Redis)
|
112
|
+
it 'should delete rack attack keys' do
|
113
|
+
redis = Redis.new
|
114
|
+
redis.set('key', 'value')
|
115
|
+
redis.set("#{Rack::Attack.cache.prefix}::key", 'value')
|
116
|
+
Rack::Attack.cache.store = redis
|
117
|
+
Rack::Attack.reset!
|
118
|
+
|
119
|
+
_(redis.get('key')).must_equal 'value'
|
120
|
+
_(redis.get("#{Rack::Attack.cache.prefix}::key")).must_be_nil
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
102
124
|
end
|
@@ -57,10 +57,6 @@ describe 'Rack::Attack.throttle' do
|
|
57
57
|
|
58
58
|
_(last_request.env['rack.attack.match_discriminator']).must_equal('1.2.3.4')
|
59
59
|
end
|
60
|
-
|
61
|
-
it 'should set a Retry-After header' do
|
62
|
-
_(last_response.headers['Retry-After']).must_equal @period.to_s
|
63
|
-
end
|
64
60
|
end
|
65
61
|
end
|
66
62
|
|
data/spec/spec_helper.rb
CHANGED
@@ -30,22 +30,19 @@ class MiniTest::Spec
|
|
30
30
|
|
31
31
|
before do
|
32
32
|
Rails.cache = nil
|
33
|
-
@_original_throttled_response = Rack::Attack.throttled_response
|
34
|
-
@_original_blocklisted_response = Rack::Attack.blocklisted_response
|
35
33
|
end
|
36
34
|
|
37
35
|
after do
|
38
36
|
Rack::Attack.clear_configuration
|
39
37
|
Rack::Attack.instance_variable_set(:@cache, nil)
|
40
|
-
|
41
|
-
Rack::Attack.throttled_response = @_original_throttled_response
|
42
|
-
Rack::Attack.blocklisted_response = @_original_blocklisted_response
|
43
38
|
end
|
44
39
|
|
45
40
|
def app
|
46
41
|
Rack::Builder.new do
|
47
42
|
# Use Rack::Lint to test that rack-attack is complying with the rack spec
|
48
43
|
use Rack::Lint
|
44
|
+
# Intentionally added twice to test idempotence property
|
45
|
+
use Rack::Attack
|
49
46
|
use Rack::Attack
|
50
47
|
use Rack::Lint
|
51
48
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack-attack
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 6.
|
4
|
+
version: 6.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Aaron Suggs
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-01-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rack
|
@@ -126,14 +126,14 @@ dependencies:
|
|
126
126
|
requirements:
|
127
127
|
- - '='
|
128
128
|
- !ruby/object:Gem::Version
|
129
|
-
version: 0.
|
129
|
+
version: 0.89.1
|
130
130
|
type: :development
|
131
131
|
prerelease: false
|
132
132
|
version_requirements: !ruby/object:Gem::Requirement
|
133
133
|
requirements:
|
134
134
|
- - '='
|
135
135
|
- !ruby/object:Gem::Version
|
136
|
-
version: 0.
|
136
|
+
version: 0.89.1
|
137
137
|
- !ruby/object:Gem::Dependency
|
138
138
|
name: rubocop-performance
|
139
139
|
requirement: !ruby/object:Gem::Requirement
|
@@ -185,7 +185,7 @@ dependencies:
|
|
185
185
|
version: '4.2'
|
186
186
|
- - "<"
|
187
187
|
- !ruby/object:Gem::Version
|
188
|
-
version: '6.
|
188
|
+
version: '6.2'
|
189
189
|
type: :development
|
190
190
|
prerelease: false
|
191
191
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -195,7 +195,7 @@ dependencies:
|
|
195
195
|
version: '4.2'
|
196
196
|
- - "<"
|
197
197
|
- !ruby/object:Gem::Version
|
198
|
-
version: '6.
|
198
|
+
version: '6.2'
|
199
199
|
description: A rack middleware for throttling and blocking abusive requests
|
200
200
|
email: aaron@ktheory.com
|
201
201
|
executables: []
|
@@ -204,12 +204,12 @@ extra_rdoc_files: []
|
|
204
204
|
files:
|
205
205
|
- README.md
|
206
206
|
- Rakefile
|
207
|
-
- bin/setup
|
208
207
|
- lib/rack/attack.rb
|
209
208
|
- lib/rack/attack/allow2ban.rb
|
210
209
|
- lib/rack/attack/blocklist.rb
|
211
210
|
- lib/rack/attack/cache.rb
|
212
211
|
- lib/rack/attack/check.rb
|
212
|
+
- lib/rack/attack/configuration.rb
|
213
213
|
- lib/rack/attack/fail2ban.rb
|
214
214
|
- lib/rack/attack/path_normalizer.rb
|
215
215
|
- lib/rack/attack/railtie.rb
|
@@ -267,13 +267,13 @@ files:
|
|
267
267
|
- spec/rack_attack_track_spec.rb
|
268
268
|
- spec/spec_helper.rb
|
269
269
|
- spec/support/cache_store_helper.rb
|
270
|
-
homepage: https://github.com/
|
270
|
+
homepage: https://github.com/rack/rack-attack
|
271
271
|
licenses:
|
272
272
|
- MIT
|
273
273
|
metadata:
|
274
|
-
bug_tracker_uri: https://github.com/
|
275
|
-
changelog_uri: https://github.com/
|
276
|
-
source_code_uri: https://github.com/
|
274
|
+
bug_tracker_uri: https://github.com/rack/rack-attack/issues
|
275
|
+
changelog_uri: https://github.com/rack/rack-attack/blob/master/CHANGELOG.md
|
276
|
+
source_code_uri: https://github.com/rack/rack-attack
|
277
277
|
post_install_message:
|
278
278
|
rdoc_options:
|
279
279
|
- "--charset=UTF-8"
|
@@ -283,57 +283,57 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
283
283
|
requirements:
|
284
284
|
- - ">="
|
285
285
|
- !ruby/object:Gem::Version
|
286
|
-
version: '2.
|
286
|
+
version: '2.4'
|
287
287
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
288
288
|
requirements:
|
289
289
|
- - ">="
|
290
290
|
- !ruby/object:Gem::Version
|
291
291
|
version: '0'
|
292
292
|
requirements: []
|
293
|
-
rubygems_version: 3.
|
293
|
+
rubygems_version: 3.2.6
|
294
294
|
signing_key:
|
295
295
|
specification_version: 4
|
296
296
|
summary: Block & throttle abusive requests
|
297
297
|
test_files:
|
298
|
-
- spec/integration/offline_spec.rb
|
299
|
-
- spec/rack_attack_path_normalizer_spec.rb
|
300
|
-
- spec/acceptance/safelisting_subnet_spec.rb
|
301
|
-
- spec/acceptance/rails_middleware_spec.rb
|
302
|
-
- spec/acceptance/track_throttle_spec.rb
|
303
|
-
- spec/acceptance/cache_store_config_for_fail2ban_spec.rb
|
304
|
-
- spec/acceptance/cache_store_config_with_rails_spec.rb
|
305
|
-
- spec/acceptance/cache_store_config_for_allow2ban_spec.rb
|
306
|
-
- spec/acceptance/safelisting_ip_spec.rb
|
307
|
-
- spec/acceptance/track_spec.rb
|
308
|
-
- spec/acceptance/blocking_subnet_spec.rb
|
309
|
-
- spec/acceptance/blocking_ip_spec.rb
|
310
298
|
- spec/acceptance/allow2ban_spec.rb
|
311
|
-
- spec/acceptance/
|
299
|
+
- spec/acceptance/blocking_ip_spec.rb
|
312
300
|
- spec/acceptance/blocking_spec.rb
|
301
|
+
- spec/acceptance/blocking_subnet_spec.rb
|
302
|
+
- spec/acceptance/cache_store_config_for_allow2ban_spec.rb
|
303
|
+
- spec/acceptance/cache_store_config_for_fail2ban_spec.rb
|
304
|
+
- spec/acceptance/cache_store_config_for_throttle_spec.rb
|
305
|
+
- spec/acceptance/cache_store_config_with_rails_spec.rb
|
306
|
+
- spec/acceptance/customizing_blocked_response_spec.rb
|
313
307
|
- spec/acceptance/customizing_throttled_response_spec.rb
|
314
308
|
- spec/acceptance/extending_request_object_spec.rb
|
315
|
-
- spec/acceptance/safelisting_spec.rb
|
316
|
-
- spec/acceptance/cache_store_config_for_throttle_spec.rb
|
317
309
|
- spec/acceptance/fail2ban_spec.rb
|
310
|
+
- spec/acceptance/rails_middleware_spec.rb
|
311
|
+
- spec/acceptance/safelisting_ip_spec.rb
|
312
|
+
- spec/acceptance/safelisting_spec.rb
|
313
|
+
- spec/acceptance/safelisting_subnet_spec.rb
|
314
|
+
- spec/acceptance/stores/active_support_dalli_store_spec.rb
|
318
315
|
- spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb
|
319
|
-
- spec/acceptance/stores/active_support_redis_cache_store_spec.rb
|
320
|
-
- spec/acceptance/stores/active_support_memory_store_spec.rb
|
321
|
-
- spec/acceptance/stores/active_support_redis_store_spec.rb
|
322
316
|
- spec/acceptance/stores/active_support_mem_cache_store_spec.rb
|
317
|
+
- spec/acceptance/stores/active_support_memory_store_spec.rb
|
323
318
|
- spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb
|
319
|
+
- spec/acceptance/stores/active_support_redis_cache_store_spec.rb
|
320
|
+
- spec/acceptance/stores/active_support_redis_store_spec.rb
|
324
321
|
- spec/acceptance/stores/connection_pool_dalli_client_spec.rb
|
325
|
-
- spec/acceptance/stores/active_support_dalli_store_spec.rb
|
326
|
-
- spec/acceptance/stores/redis_store_spec.rb
|
327
322
|
- spec/acceptance/stores/dalli_client_spec.rb
|
328
323
|
- spec/acceptance/stores/redis_spec.rb
|
329
|
-
- spec/acceptance/
|
330
|
-
- spec/
|
324
|
+
- spec/acceptance/stores/redis_store_spec.rb
|
325
|
+
- spec/acceptance/throttling_spec.rb
|
326
|
+
- spec/acceptance/track_spec.rb
|
327
|
+
- spec/acceptance/track_throttle_spec.rb
|
331
328
|
- spec/allow2ban_spec.rb
|
332
|
-
- spec/
|
329
|
+
- spec/fail2ban_spec.rb
|
330
|
+
- spec/integration/offline_spec.rb
|
333
331
|
- spec/rack_attack_dalli_proxy_spec.rb
|
332
|
+
- spec/rack_attack_instrumentation_spec.rb
|
333
|
+
- spec/rack_attack_path_normalizer_spec.rb
|
334
|
+
- spec/rack_attack_request_spec.rb
|
334
335
|
- spec/rack_attack_spec.rb
|
335
336
|
- spec/rack_attack_throttle_spec.rb
|
336
|
-
- spec/rack_attack_request_spec.rb
|
337
|
-
- spec/fail2ban_spec.rb
|
338
337
|
- spec/rack_attack_track_spec.rb
|
338
|
+
- spec/spec_helper.rb
|
339
339
|
- spec/support/cache_store_helper.rb
|