rack-attack 5.3.1 → 5.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 +3 -3
- data/Rakefile +3 -2
- data/lib/rack/attack.rb +23 -22
- data/lib/rack/attack/cache.rb +4 -3
- data/lib/rack/attack/check.rb +6 -8
- data/lib/rack/attack/store_proxy.rb +1 -1
- data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +2 -2
- data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
- data/lib/rack/attack/store_proxy/redis_store_proxy.rb +1 -22
- data/lib/rack/attack/throttle.rb +14 -11
- data/lib/rack/attack/track.rb +3 -3
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/stores/active_support_dalli_store_spec.rb +41 -0
- data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +40 -0
- data/spec/acceptance/stores/{mem_cache_store_spec.rb → active_support_memory_store_spec.rb} +5 -5
- data/spec/acceptance/stores/{redis_cache_store_pooled_spec.rb → active_support_redis_cache_store_pooled_spec.rb} +4 -4
- data/spec/acceptance/stores/{redis_cache_store_spec.rb → active_support_redis_cache_store_spec.rb} +4 -4
- data/spec/acceptance/stores/active_support_redis_store_spec.rb +40 -0
- data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +42 -0
- data/spec/acceptance/stores/dalli_client_spec.rb +41 -0
- data/spec/acceptance/stores/redis_spec.rb +42 -0
- data/spec/acceptance/stores/redis_store_spec.rb +40 -0
- data/spec/integration/offline_spec.rb +21 -19
- data/spec/rack_attack_throttle_spec.rb +4 -4
- data/spec/rack_attack_track_spec.rb +4 -4
- data/spec/spec_helper.rb +15 -9
- metadata +84 -146
- data/spec/integration/rack_attack_cache_spec.rb +0 -124
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 123608043cbaa1604ab2d7b06c056010decd274dd4a5b1fe8f2175cec766fa4d
|
4
|
+
data.tar.gz: 2bd3ca91293545b76221608f144c1a43a458fbd9e6f2e8ea2647bf561d8ea9a9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '06388ad68edc65019740c9ee77347f817b0769e782d0f8564ac5ac6b9c7fa819fe2157a10d89b570c78c14ef5a86fe080fac30aba8ebdfed281f2073785f5685'
|
7
|
+
data.tar.gz: a15cc2cef9eebda52d662fe3eeee3f187d5b575c089a1fa2cf5e0179012499f6440889349a47108c4017f8b73dd18985655dc933baf8c031e08d73ea23d1dbba
|
data/README.md
CHANGED
@@ -303,13 +303,13 @@ Here's an example response that includes conventional `X-RateLimit-*` headers:
|
|
303
303
|
|
304
304
|
```ruby
|
305
305
|
Rack::Attack.throttled_response = lambda do |env|
|
306
|
-
now = Time.now
|
307
306
|
match_data = env['rack.attack.match_data']
|
307
|
+
now = match_data[:epoch_time]
|
308
308
|
|
309
309
|
headers = {
|
310
310
|
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
311
311
|
'X-RateLimit-Remaining' => '0',
|
312
|
-
'X-RateLimit-Reset' => (now + (match_data[:period] - now
|
312
|
+
'X-RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
|
313
313
|
}
|
314
314
|
|
315
315
|
[ 429, headers, ["Throttled\n"]]
|
@@ -320,7 +320,7 @@ end
|
|
320
320
|
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
|
321
321
|
|
322
322
|
```ruby
|
323
|
-
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l }
|
323
|
+
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t }
|
324
324
|
```
|
325
325
|
|
326
326
|
## Logging & Instrumentation
|
data/Rakefile
CHANGED
data/lib/rack/attack.rb
CHANGED
@@ -17,6 +17,7 @@ class Rack::Attack
|
|
17
17
|
autoload :StoreProxy, 'rack/attack/store_proxy'
|
18
18
|
autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
|
19
19
|
autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
|
20
|
+
autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
|
20
21
|
autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
|
21
22
|
autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
|
22
23
|
autoload :Fail2Ban, 'rack/attack/fail2ban'
|
@@ -81,40 +82,40 @@ class Rack::Attack
|
|
81
82
|
blocklists
|
82
83
|
end
|
83
84
|
|
84
|
-
def safelisted?(
|
85
|
-
ip_safelists.any? { |safelist| safelist.
|
86
|
-
safelists.any? { |_name, safelist| safelist.
|
85
|
+
def safelisted?(request)
|
86
|
+
ip_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
87
|
+
safelists.any? { |_name, safelist| safelist.matched_by?(request) }
|
87
88
|
end
|
88
89
|
|
89
|
-
def whitelisted?(
|
90
|
+
def whitelisted?(request)
|
90
91
|
warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead."
|
91
|
-
safelisted?(
|
92
|
+
safelisted?(request)
|
92
93
|
end
|
93
94
|
|
94
|
-
def blocklisted?(
|
95
|
-
ip_blocklists.any? { |blocklist| blocklist.
|
96
|
-
blocklists.any? { |_name, blocklist| blocklist.
|
95
|
+
def blocklisted?(request)
|
96
|
+
ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
|
97
|
+
blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
|
97
98
|
end
|
98
99
|
|
99
|
-
def blacklisted?(
|
100
|
+
def blacklisted?(request)
|
100
101
|
warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead."
|
101
|
-
blocklisted?(
|
102
|
+
blocklisted?(request)
|
102
103
|
end
|
103
104
|
|
104
|
-
def throttled?(
|
105
|
+
def throttled?(request)
|
105
106
|
throttles.any? do |_name, throttle|
|
106
|
-
throttle
|
107
|
+
throttle.matched_by?(request)
|
107
108
|
end
|
108
109
|
end
|
109
110
|
|
110
|
-
def tracked?(
|
111
|
-
tracks.each_value do |
|
112
|
-
|
111
|
+
def tracked?(request)
|
112
|
+
tracks.each_value do |track|
|
113
|
+
track.matched_by?(request)
|
113
114
|
end
|
114
115
|
end
|
115
116
|
|
116
|
-
def instrument(
|
117
|
-
notifier.instrument('rack.attack',
|
117
|
+
def instrument(request)
|
118
|
+
notifier.instrument('rack.attack', request) if notifier
|
118
119
|
end
|
119
120
|
|
120
121
|
def cache
|
@@ -167,16 +168,16 @@ class Rack::Attack
|
|
167
168
|
|
168
169
|
def call(env)
|
169
170
|
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
|
170
|
-
|
171
|
+
request = Rack::Attack::Request.new(env)
|
171
172
|
|
172
|
-
if safelisted?(
|
173
|
+
if safelisted?(request)
|
173
174
|
@app.call(env)
|
174
|
-
elsif blocklisted?(
|
175
|
+
elsif blocklisted?(request)
|
175
176
|
self.class.blocklisted_response.call(env)
|
176
|
-
elsif throttled?(
|
177
|
+
elsif throttled?(request)
|
177
178
|
self.class.throttled_response.call(env)
|
178
179
|
else
|
179
|
-
tracked?(
|
180
|
+
tracked?(request)
|
180
181
|
@app.call(env)
|
181
182
|
end
|
182
183
|
end
|
data/lib/rack/attack/cache.rb
CHANGED
@@ -2,6 +2,7 @@ module Rack
|
|
2
2
|
class Attack
|
3
3
|
class Cache
|
4
4
|
attr_accessor :prefix
|
5
|
+
attr_reader :last_epoch_time
|
5
6
|
|
6
7
|
def initialize
|
7
8
|
self.store = ::Rails.cache if defined?(::Rails.cache)
|
@@ -41,10 +42,10 @@ module Rack
|
|
41
42
|
private
|
42
43
|
|
43
44
|
def key_and_expiry(unprefixed_key, period)
|
44
|
-
|
45
|
+
@last_epoch_time = Time.now.to_i
|
45
46
|
# Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA
|
46
|
-
expires_in = (period - (
|
47
|
-
["#{prefix}:#{(
|
47
|
+
expires_in = (period - (@last_epoch_time % period) + 1).to_i
|
48
|
+
["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
|
48
49
|
end
|
49
50
|
|
50
51
|
def do_count(key, expires_in)
|
data/lib/rack/attack/check.rb
CHANGED
@@ -7,17 +7,15 @@ module Rack
|
|
7
7
|
@type = options.fetch(:type, nil)
|
8
8
|
end
|
9
9
|
|
10
|
-
def
|
11
|
-
block
|
10
|
+
def matched_by?(request)
|
11
|
+
block.call(request).tap do |match|
|
12
12
|
if match
|
13
|
-
|
14
|
-
|
15
|
-
Rack::Attack.instrument(
|
13
|
+
request.env["rack.attack.matched"] = name
|
14
|
+
request.env["rack.attack.match_type"] = type
|
15
|
+
Rack::Attack.instrument(request)
|
16
16
|
end
|
17
|
-
|
17
|
+
end
|
18
18
|
end
|
19
|
-
|
20
|
-
alias_method :match?, :[]
|
21
19
|
end
|
22
20
|
end
|
23
21
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module Rack
|
2
2
|
class Attack
|
3
3
|
module StoreProxy
|
4
|
-
PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisCacheStoreProxy].freeze
|
4
|
+
PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
|
5
5
|
|
6
6
|
ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore', 'ActiveSupport::Cache::RedisCacheStore']).freeze
|
7
7
|
ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze
|
@@ -5,7 +5,7 @@ module Rack
|
|
5
5
|
module StoreProxy
|
6
6
|
class RedisCacheStoreProxy < SimpleDelegator
|
7
7
|
def self.handle?(store)
|
8
|
-
defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
|
8
|
+
defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
|
9
9
|
end
|
10
10
|
|
11
11
|
def increment(name, amount = 1, options = {})
|
@@ -16,7 +16,7 @@ module Rack
|
|
16
16
|
if options[:expires_in] && !read(name)
|
17
17
|
write(name, amount, options)
|
18
18
|
|
19
|
-
|
19
|
+
amount
|
20
20
|
else
|
21
21
|
super
|
22
22
|
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'delegate'
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class Attack
|
7
|
+
module StoreProxy
|
8
|
+
class RedisProxy < SimpleDelegator
|
9
|
+
def initialize(*args)
|
10
|
+
if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3")
|
11
|
+
warn 'RackAttack requires Redis gem >= 3.0.0.'
|
12
|
+
end
|
13
|
+
|
14
|
+
super(*args)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.handle?(store)
|
18
|
+
defined?(::Redis) && store.is_a?(::Redis)
|
19
|
+
end
|
20
|
+
|
21
|
+
def read(key)
|
22
|
+
get(key)
|
23
|
+
rescue Redis::BaseError
|
24
|
+
end
|
25
|
+
|
26
|
+
def write(key, value, options = {})
|
27
|
+
if (expires_in = options[:expires_in])
|
28
|
+
setex(key, expires_in, value)
|
29
|
+
else
|
30
|
+
set(key, value)
|
31
|
+
end
|
32
|
+
rescue Redis::BaseError
|
33
|
+
end
|
34
|
+
|
35
|
+
def increment(key, amount, options = {})
|
36
|
+
count = nil
|
37
|
+
|
38
|
+
pipelined do
|
39
|
+
count = incrby(key, amount)
|
40
|
+
expire(key, options[:expires_in]) if options[:expires_in]
|
41
|
+
end
|
42
|
+
|
43
|
+
count.value if count
|
44
|
+
rescue Redis::BaseError
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(key, _options = {})
|
48
|
+
del(key)
|
49
|
+
rescue Redis::BaseError
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -3,15 +3,11 @@ require 'delegate'
|
|
3
3
|
module Rack
|
4
4
|
class Attack
|
5
5
|
module StoreProxy
|
6
|
-
class RedisStoreProxy <
|
6
|
+
class RedisStoreProxy < RedisProxy
|
7
7
|
def self.handle?(store)
|
8
8
|
defined?(::Redis::Store) && store.is_a?(::Redis::Store)
|
9
9
|
end
|
10
10
|
|
11
|
-
def initialize(store)
|
12
|
-
super(store)
|
13
|
-
end
|
14
|
-
|
15
11
|
def read(key)
|
16
12
|
get(key, raw: true)
|
17
13
|
rescue Redis::BaseError
|
@@ -25,23 +21,6 @@ module Rack
|
|
25
21
|
end
|
26
22
|
rescue Redis::BaseError
|
27
23
|
end
|
28
|
-
|
29
|
-
def increment(key, amount, options = {})
|
30
|
-
count = nil
|
31
|
-
|
32
|
-
pipelined do
|
33
|
-
count = incrby(key, amount)
|
34
|
-
expire(key, options[:expires_in]) if options[:expires_in]
|
35
|
-
end
|
36
|
-
|
37
|
-
count.value if count
|
38
|
-
rescue Redis::BaseError
|
39
|
-
end
|
40
|
-
|
41
|
-
def delete(key, _options = {})
|
42
|
-
del(key)
|
43
|
-
rescue Redis::BaseError
|
44
|
-
end
|
45
24
|
end
|
46
25
|
end
|
47
26
|
end
|
data/lib/rack/attack/throttle.rb
CHANGED
@@ -18,29 +18,32 @@ module Rack
|
|
18
18
|
Rack::Attack.cache
|
19
19
|
end
|
20
20
|
|
21
|
-
def
|
22
|
-
discriminator = block
|
21
|
+
def matched_by?(request)
|
22
|
+
discriminator = block.call(request)
|
23
23
|
return false unless discriminator
|
24
24
|
|
25
|
-
current_period = period.respond_to?(:call) ? period.call(
|
26
|
-
current_limit = limit.respond_to?(:call) ? limit.call(
|
25
|
+
current_period = period.respond_to?(:call) ? period.call(request) : period
|
26
|
+
current_limit = limit.respond_to?(:call) ? limit.call(request) : limit
|
27
27
|
key = "#{name}:#{discriminator}"
|
28
28
|
count = cache.count(key, current_period)
|
29
|
+
epoch_time = cache.last_epoch_time
|
29
30
|
|
30
31
|
data = {
|
31
32
|
:count => count,
|
32
33
|
:period => current_period,
|
33
|
-
:limit => current_limit
|
34
|
+
:limit => current_limit,
|
35
|
+
:epoch_time => epoch_time
|
34
36
|
}
|
35
|
-
|
37
|
+
|
38
|
+
(request.env['rack.attack.throttle_data'] ||= {})[name] = data
|
36
39
|
|
37
40
|
(count > current_limit).tap do |throttled|
|
38
41
|
if throttled
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
Rack::Attack.instrument(
|
42
|
+
request.env['rack.attack.matched'] = name
|
43
|
+
request.env['rack.attack.match_discriminator'] = discriminator
|
44
|
+
request.env['rack.attack.match_type'] = type
|
45
|
+
request.env['rack.attack.match_data'] = data
|
46
|
+
Rack::Attack.instrument(request)
|
44
47
|
end
|
45
48
|
end
|
46
49
|
end
|
data/lib/rack/attack/track.rb
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
module Rack
|
2
2
|
class Attack
|
3
3
|
class Track
|
4
|
-
extend Forwardable
|
5
|
-
|
6
4
|
attr_reader :filter
|
7
5
|
|
8
6
|
def initialize(name, options = {}, block)
|
@@ -15,7 +13,9 @@ module Rack
|
|
15
13
|
end
|
16
14
|
end
|
17
15
|
|
18
|
-
|
16
|
+
def matched_by?(request)
|
17
|
+
filter.matched_by?(request)
|
18
|
+
end
|
19
19
|
end
|
20
20
|
end
|
21
21
|
end
|
data/lib/rack/attack/version.rb
CHANGED
@@ -0,0 +1,41 @@
|
|
1
|
+
require_relative "../../spec_helper"
|
2
|
+
|
3
|
+
if defined?(::Dalli)
|
4
|
+
require_relative "../../support/cache_store_helper"
|
5
|
+
require "active_support/cache/dalli_store"
|
6
|
+
require "timecop"
|
7
|
+
|
8
|
+
describe "ActiveSupport::Cache::DalliStore as a cache backend" do
|
9
|
+
before do
|
10
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::DalliStore.new
|
11
|
+
end
|
12
|
+
|
13
|
+
after do
|
14
|
+
Rack::Attack.cache.store.clear
|
15
|
+
end
|
16
|
+
|
17
|
+
it_works_for_cache_backed_features
|
18
|
+
|
19
|
+
it "doesn't leak keys" do
|
20
|
+
Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request|
|
21
|
+
request.ip
|
22
|
+
end
|
23
|
+
|
24
|
+
key = nil
|
25
|
+
|
26
|
+
# Freeze time during these statement to be sure that the key used by rack attack is the same
|
27
|
+
# we pre-calculate in local variable `key`
|
28
|
+
Timecop.freeze do
|
29
|
+
key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4"
|
30
|
+
|
31
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
32
|
+
end
|
33
|
+
|
34
|
+
assert Rack::Attack.cache.store.fetch(key)
|
35
|
+
|
36
|
+
sleep 2.1
|
37
|
+
|
38
|
+
assert_nil Rack::Attack.cache.store.fetch(key)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require_relative "../../spec_helper"
|
2
|
+
|
3
|
+
if defined?(::Dalli)
|
4
|
+
require_relative "../../support/cache_store_helper"
|
5
|
+
require "timecop"
|
6
|
+
|
7
|
+
describe "ActiveSupport::Cache::MemCacheStore as a cache backend" do
|
8
|
+
before do
|
9
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemCacheStore.new
|
10
|
+
end
|
11
|
+
|
12
|
+
after do
|
13
|
+
Rack::Attack.cache.store.flush_all
|
14
|
+
end
|
15
|
+
|
16
|
+
it_works_for_cache_backed_features
|
17
|
+
|
18
|
+
it "doesn't leak keys" do
|
19
|
+
Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request|
|
20
|
+
request.ip
|
21
|
+
end
|
22
|
+
|
23
|
+
key = nil
|
24
|
+
|
25
|
+
# Freeze time during these statement to be sure that the key used by rack attack is the same
|
26
|
+
# we pre-calculate in local variable `key`
|
27
|
+
Timecop.freeze do
|
28
|
+
key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4"
|
29
|
+
|
30
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
31
|
+
end
|
32
|
+
|
33
|
+
assert Rack::Attack.cache.store.get(key)
|
34
|
+
|
35
|
+
sleep 2.1
|
36
|
+
|
37
|
+
assert_nil Rack::Attack.cache.store.get(key)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|