rack-attack 4.3.1 → 5.4.2
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 +5 -5
- data/README.md +230 -113
- data/Rakefile +11 -3
- data/bin/setup +8 -0
- data/lib/rack/attack.rb +121 -48
- data/lib/rack/attack/allow2ban.rb +2 -1
- data/lib/rack/attack/{whitelist.rb → blocklist.rb} +2 -3
- data/lib/rack/attack/cache.rb +24 -5
- data/lib/rack/attack/check.rb +6 -8
- data/lib/rack/attack/fail2ban.rb +3 -2
- data/lib/rack/attack/path_normalizer.rb +6 -11
- data/lib/rack/attack/request.rb +1 -1
- data/lib/rack/attack/{blacklist.rb → safelist.rb} +2 -4
- data/lib/rack/attack/store_proxy.rb +13 -12
- data/lib/rack/attack/store_proxy/dalli_proxy.rb +2 -3
- data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +50 -0
- data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +19 -0
- data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +35 -0
- data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
- data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -24
- data/lib/rack/attack/throttle.rb +16 -12
- data/lib/rack/attack/track.rb +3 -3
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/allow2ban_spec.rb +71 -0
- data/spec/acceptance/blocking_ip_spec.rb +38 -0
- data/spec/acceptance/blocking_spec.rb +41 -0
- data/spec/acceptance/blocking_subnet_spec.rb +44 -0
- data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +126 -0
- data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +121 -0
- data/spec/acceptance/cache_store_config_for_throttle_spec.rb +48 -0
- data/spec/acceptance/cache_store_config_with_rails_spec.rb +31 -0
- data/spec/acceptance/customizing_blocked_response_spec.rb +41 -0
- data/spec/acceptance/customizing_throttled_response_spec.rb +59 -0
- data/spec/acceptance/extending_request_object_spec.rb +34 -0
- data/spec/acceptance/fail2ban_spec.rb +76 -0
- data/spec/acceptance/safelisting_ip_spec.rb +48 -0
- data/spec/acceptance/safelisting_spec.rb +53 -0
- data/spec/acceptance/safelisting_subnet_spec.rb +48 -0
- data/spec/acceptance/stores/active_support_dalli_store_spec.rb +19 -0
- data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +22 -0
- data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +18 -0
- data/spec/acceptance/stores/active_support_memory_store_spec.rb +16 -0
- data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +18 -0
- data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +18 -0
- data/spec/acceptance/stores/active_support_redis_store_spec.rb +18 -0
- data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +22 -0
- data/spec/acceptance/stores/dalli_client_spec.rb +19 -0
- data/spec/acceptance/stores/redis_spec.rb +20 -0
- data/spec/acceptance/stores/redis_store_spec.rb +18 -0
- data/spec/acceptance/throttling_spec.rb +159 -0
- data/spec/acceptance/track_spec.rb +27 -0
- data/spec/acceptance/track_throttle_spec.rb +53 -0
- data/spec/allow2ban_spec.rb +10 -9
- data/spec/fail2ban_spec.rb +12 -10
- data/spec/integration/offline_spec.rb +21 -23
- data/spec/rack_attack_dalli_proxy_spec.rb +0 -2
- data/spec/rack_attack_request_spec.rb +2 -2
- data/spec/rack_attack_spec.rb +53 -18
- data/spec/rack_attack_throttle_spec.rb +45 -13
- data/spec/rack_attack_track_spec.rb +11 -8
- data/spec/spec_helper.rb +35 -14
- data/spec/support/cache_store_helper.rb +82 -0
- metadata +161 -61
- data/spec/integration/rack_attack_cache_spec.rb +0 -119
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
module Rack
|
|
2
2
|
class Attack
|
|
3
3
|
module StoreProxy
|
|
4
|
-
PROXIES = [DalliProxy, RedisStoreProxy]
|
|
4
|
+
PROXIES = [DalliProxy, MemCacheStoreProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
|
|
5
5
|
|
|
6
6
|
def self.build(store)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
|
|
11
|
-
# so use the raw Redis::Store instead
|
|
12
|
-
store = store.instance_variable_get(:@data)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
klass = PROXIES.find { |proxy| proxy.handle?(store) }
|
|
16
|
-
|
|
17
|
-
klass ? klass.new(store) : store
|
|
7
|
+
client = unwrap_active_support_stores(store)
|
|
8
|
+
klass = PROXIES.find { |proxy| proxy.handle?(client) }
|
|
9
|
+
klass ? klass.new(client) : client
|
|
18
10
|
end
|
|
19
11
|
|
|
12
|
+
def self.unwrap_active_support_stores(store)
|
|
13
|
+
# ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
|
|
14
|
+
# so use the raw Redis::Store instead.
|
|
15
|
+
if store.class.name == 'ActiveSupport::Cache::RedisStore'
|
|
16
|
+
store.instance_variable_get(:@data)
|
|
17
|
+
else
|
|
18
|
+
store
|
|
19
|
+
end
|
|
20
|
+
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
end
|
|
@@ -28,14 +28,14 @@ module Rack
|
|
|
28
28
|
rescue Dalli::DalliError
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def write(key, value, options={})
|
|
31
|
+
def write(key, value, options = {})
|
|
32
32
|
with do |client|
|
|
33
33
|
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
|
|
34
34
|
end
|
|
35
35
|
rescue Dalli::DalliError
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def increment(key, amount, options={})
|
|
38
|
+
def increment(key, amount, options = {})
|
|
39
39
|
with do |client|
|
|
40
40
|
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
|
|
41
41
|
end
|
|
@@ -58,7 +58,6 @@ module Rack
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
|
-
|
|
62
61
|
end
|
|
63
62
|
end
|
|
64
63
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
class Attack
|
|
3
|
+
module StoreProxy
|
|
4
|
+
class MemCacheProxy < SimpleDelegator
|
|
5
|
+
def self.handle?(store)
|
|
6
|
+
defined?(::MemCache) && store.is_a?(::MemCache)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(store)
|
|
10
|
+
super(store)
|
|
11
|
+
stub_with_if_missing
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read(key)
|
|
15
|
+
# Second argument: reading raw value
|
|
16
|
+
get(key, true)
|
|
17
|
+
rescue MemCache::MemCacheError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(key, value, options = {})
|
|
21
|
+
# Third argument: writing raw value
|
|
22
|
+
set(key, value, options.fetch(:expires_in, 0), true)
|
|
23
|
+
rescue MemCache::MemCacheError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def increment(key, amount, _options = {})
|
|
27
|
+
incr(key, amount)
|
|
28
|
+
rescue MemCache::MemCacheError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete(key, _options = {})
|
|
32
|
+
with do |client|
|
|
33
|
+
client.delete(key)
|
|
34
|
+
end
|
|
35
|
+
rescue MemCache::MemCacheError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def stub_with_if_missing
|
|
41
|
+
unless __getobj__.respond_to?(:with)
|
|
42
|
+
class << self
|
|
43
|
+
def with; yield __getobj__; end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
|
|
5
|
+
module Rack
|
|
6
|
+
class Attack
|
|
7
|
+
module StoreProxy
|
|
8
|
+
class MemCacheStoreProxy < SimpleDelegator
|
|
9
|
+
def self.handle?(store)
|
|
10
|
+
defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) && store.is_a?(::ActiveSupport::Cache::MemCacheStore)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write(name, value, options = {})
|
|
14
|
+
super(name, value, options.merge!(raw: true))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require 'delegate'
|
|
2
|
+
|
|
3
|
+
module Rack
|
|
4
|
+
class Attack
|
|
5
|
+
module StoreProxy
|
|
6
|
+
class RedisCacheStoreProxy < SimpleDelegator
|
|
7
|
+
def self.handle?(store)
|
|
8
|
+
store.class.name == "ActiveSupport::Cache::RedisCacheStore"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def increment(name, amount = 1, options = {})
|
|
12
|
+
# RedisCacheStore#increment ignores options[:expires_in].
|
|
13
|
+
#
|
|
14
|
+
# So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize
|
|
15
|
+
# the counter. After that we continue using the original RedisCacheStore#increment.
|
|
16
|
+
if options[:expires_in] && !read(name)
|
|
17
|
+
write(name, amount, options)
|
|
18
|
+
|
|
19
|
+
amount
|
|
20
|
+
else
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(name, options = {})
|
|
26
|
+
super(name, options.merge!(raw: true))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(name, value, options = {})
|
|
30
|
+
super(name, value, options.merge!(raw: true))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
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,43 +3,24 @@ 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
|
|
18
14
|
end
|
|
19
15
|
|
|
20
|
-
def write(key, value, options={})
|
|
16
|
+
def write(key, value, options = {})
|
|
21
17
|
if (expires_in = options[:expires_in])
|
|
22
|
-
|
|
18
|
+
setex(key, expires_in, value, raw: true)
|
|
23
19
|
else
|
|
24
|
-
|
|
20
|
+
set(key, value, raw: true)
|
|
25
21
|
end
|
|
26
22
|
rescue Redis::BaseError
|
|
27
23
|
end
|
|
28
|
-
|
|
29
|
-
def increment(key, amount, options={})
|
|
30
|
-
count = nil
|
|
31
|
-
self.pipelined do
|
|
32
|
-
count = self.incrby(key, amount)
|
|
33
|
-
self.expire(key, options[:expires_in]) if options[:expires_in]
|
|
34
|
-
end
|
|
35
|
-
count.value if count
|
|
36
|
-
rescue Redis::BaseError
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def delete(key, options={})
|
|
40
|
-
self.del(key)
|
|
41
|
-
rescue Redis::BaseError
|
|
42
|
-
end
|
|
43
24
|
end
|
|
44
25
|
end
|
|
45
26
|
end
|
data/lib/rack/attack/throttle.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module Rack
|
|
2
2
|
class Attack
|
|
3
3
|
class Throttle
|
|
4
|
-
MANDATORY_OPTIONS = [:limit, :period]
|
|
4
|
+
MANDATORY_OPTIONS = [:limit, :period].freeze
|
|
5
|
+
|
|
5
6
|
attr_reader :name, :limit, :period, :block, :type
|
|
6
7
|
def initialize(name, options, block)
|
|
7
8
|
@name, @block = name, block
|
|
@@ -17,29 +18,32 @@ module Rack
|
|
|
17
18
|
Rack::Attack.cache
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
def
|
|
21
|
-
discriminator = block
|
|
21
|
+
def matched_by?(request)
|
|
22
|
+
discriminator = block.call(request)
|
|
22
23
|
return false unless discriminator
|
|
23
24
|
|
|
24
|
-
current_period = period.respond_to?(:call) ? period.call(
|
|
25
|
-
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
|
|
26
27
|
key = "#{name}:#{discriminator}"
|
|
27
28
|
count = cache.count(key, current_period)
|
|
29
|
+
epoch_time = cache.last_epoch_time
|
|
28
30
|
|
|
29
31
|
data = {
|
|
30
32
|
:count => count,
|
|
31
33
|
:period => current_period,
|
|
32
|
-
:limit => current_limit
|
|
34
|
+
:limit => current_limit,
|
|
35
|
+
:epoch_time => epoch_time
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
|
|
38
|
+
(request.env['rack.attack.throttle_data'] ||= {})[name] = data
|
|
35
39
|
|
|
36
40
|
(count > current_limit).tap do |throttled|
|
|
37
41
|
if throttled
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
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)
|
|
43
47
|
end
|
|
44
48
|
end
|
|
45
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,71 @@
|
|
|
1
|
+
require_relative "../spec_helper"
|
|
2
|
+
require "timecop"
|
|
3
|
+
|
|
4
|
+
describe "allow2ban" do
|
|
5
|
+
before do
|
|
6
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
|
7
|
+
|
|
8
|
+
Rack::Attack.blocklist("allow2ban pentesters") do |request|
|
|
9
|
+
Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do
|
|
10
|
+
request.path.include?("scarce-resource")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "returns OK for many requests that doesn't match the filter" do
|
|
16
|
+
get "/"
|
|
17
|
+
assert_equal 200, last_response.status
|
|
18
|
+
|
|
19
|
+
get "/"
|
|
20
|
+
assert_equal 200, last_response.status
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "returns OK for first request that matches the filter" do
|
|
24
|
+
get "/scarce-resource"
|
|
25
|
+
assert_equal 200, last_response.status
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "forbids all access after reaching maxretry limit" do
|
|
29
|
+
get "/scarce-resource"
|
|
30
|
+
assert_equal 200, last_response.status
|
|
31
|
+
|
|
32
|
+
get "/scarce-resource"
|
|
33
|
+
assert_equal 200, last_response.status
|
|
34
|
+
|
|
35
|
+
get "/scarce-resource"
|
|
36
|
+
assert_equal 403, last_response.status
|
|
37
|
+
|
|
38
|
+
get "/"
|
|
39
|
+
assert_equal 403, last_response.status
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "restores access after bantime elapsed" do
|
|
43
|
+
get "/scarce-resource"
|
|
44
|
+
assert_equal 200, last_response.status
|
|
45
|
+
|
|
46
|
+
get "/scarce-resource"
|
|
47
|
+
assert_equal 200, last_response.status
|
|
48
|
+
|
|
49
|
+
get "/"
|
|
50
|
+
assert_equal 403, last_response.status
|
|
51
|
+
|
|
52
|
+
Timecop.travel(60) do
|
|
53
|
+
get "/"
|
|
54
|
+
|
|
55
|
+
assert_equal 200, last_response.status
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "does not forbid all access if maxrety condition is met but not within the findtime timespan" do
|
|
60
|
+
get "/scarce-resource"
|
|
61
|
+
assert_equal 200, last_response.status
|
|
62
|
+
|
|
63
|
+
Timecop.travel(31) do
|
|
64
|
+
get "/scarce-resource"
|
|
65
|
+
assert_equal 200, last_response.status
|
|
66
|
+
|
|
67
|
+
get "/"
|
|
68
|
+
assert_equal 200, last_response.status
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|