rack-attack 5.0.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 +190 -94
- data/Rakefile +11 -4
- data/bin/setup +8 -0
- data/lib/rack/attack.rb +83 -51
- data/lib/rack/attack/allow2ban.rb +2 -1
- data/lib/rack/attack/blocklist.rb +0 -1
- data/lib/rack/attack/cache.rb +24 -5
- data/lib/rack/attack/check.rb +6 -8
- data/lib/rack/attack/fail2ban.rb +2 -1
- data/lib/rack/attack/path_normalizer.rb +6 -11
- data/lib/rack/attack/safelist.rb +0 -1
- data/lib/rack/attack/store_proxy.rb +3 -12
- data/lib/rack/attack/store_proxy/dalli_proxy.rb +2 -3
- data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +4 -5
- 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 +9 -8
- data/spec/fail2ban_spec.rb +11 -9
- 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 +1 -1
- data/spec/rack_attack_spec.rb +13 -14
- data/spec/rack_attack_throttle_spec.rb +28 -18
- 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 +150 -65
- data/spec/integration/rack_attack_cache_spec.rb +0 -122
|
@@ -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
|
|
@@ -14,21 +14,21 @@ module Rack
|
|
|
14
14
|
def read(key)
|
|
15
15
|
# Second argument: reading raw value
|
|
16
16
|
get(key, true)
|
|
17
|
-
|
|
17
|
+
rescue MemCache::MemCacheError
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def write(key, value, options={})
|
|
20
|
+
def write(key, value, options = {})
|
|
21
21
|
# Third argument: writing raw value
|
|
22
22
|
set(key, value, options.fetch(:expires_in, 0), true)
|
|
23
23
|
rescue MemCache::MemCacheError
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def increment(key, amount,
|
|
26
|
+
def increment(key, amount, _options = {})
|
|
27
27
|
incr(key, amount)
|
|
28
28
|
rescue MemCache::MemCacheError
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def delete(key,
|
|
31
|
+
def delete(key, _options = {})
|
|
32
32
|
with do |client|
|
|
33
33
|
client.delete(key)
|
|
34
34
|
end
|
|
@@ -44,7 +44,6 @@ module Rack
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
|
-
|
|
48
47
|
end
|
|
49
48
|
end
|
|
50
49
|
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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "../spec_helper"
|
|
2
|
+
|
|
3
|
+
describe "Blocking an IP" do
|
|
4
|
+
before do
|
|
5
|
+
Rack::Attack.blocklist_ip("1.2.3.4")
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it "forbids request if IP matches" do
|
|
9
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
|
10
|
+
|
|
11
|
+
assert_equal 403, last_response.status
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "succeeds if IP doesn't match" do
|
|
15
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
|
16
|
+
|
|
17
|
+
assert_equal 200, last_response.status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "notifies when the request is blocked" do
|
|
21
|
+
notified = false
|
|
22
|
+
notification_type = nil
|
|
23
|
+
|
|
24
|
+
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
|
|
25
|
+
notified = true
|
|
26
|
+
notification_type = request.env["rack.attack.match_type"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
|
30
|
+
|
|
31
|
+
refute notified
|
|
32
|
+
|
|
33
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
|
34
|
+
|
|
35
|
+
assert notified
|
|
36
|
+
assert_equal :blocklist, notification_type
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "../spec_helper"
|
|
2
|
+
|
|
3
|
+
describe "#blocklist" do
|
|
4
|
+
before do
|
|
5
|
+
Rack::Attack.blocklist("block 1.2.3.4") do |request|
|
|
6
|
+
request.ip == "1.2.3.4"
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it "forbids request if blocklist condition is true" do
|
|
11
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
|
12
|
+
|
|
13
|
+
assert_equal 403, last_response.status
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "succeeds if blocklist condition is false" do
|
|
17
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
|
18
|
+
|
|
19
|
+
assert_equal 200, last_response.status
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "notifies when the request is blocked" do
|
|
23
|
+
notification_matched = nil
|
|
24
|
+
notification_type = nil
|
|
25
|
+
|
|
26
|
+
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
|
|
27
|
+
notification_matched = request.env["rack.attack.matched"]
|
|
28
|
+
notification_type = request.env["rack.attack.match_type"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
|
32
|
+
|
|
33
|
+
assert_nil notification_matched
|
|
34
|
+
assert_nil notification_type
|
|
35
|
+
|
|
36
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
|
37
|
+
|
|
38
|
+
assert_equal "block 1.2.3.4", notification_matched
|
|
39
|
+
assert_equal :blocklist, notification_type
|
|
40
|
+
end
|
|
41
|
+
end
|