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.
Files changed (64) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +230 -113
  3. data/Rakefile +11 -3
  4. data/bin/setup +8 -0
  5. data/lib/rack/attack.rb +121 -48
  6. data/lib/rack/attack/allow2ban.rb +2 -1
  7. data/lib/rack/attack/{whitelist.rb → blocklist.rb} +2 -3
  8. data/lib/rack/attack/cache.rb +24 -5
  9. data/lib/rack/attack/check.rb +6 -8
  10. data/lib/rack/attack/fail2ban.rb +3 -2
  11. data/lib/rack/attack/path_normalizer.rb +6 -11
  12. data/lib/rack/attack/request.rb +1 -1
  13. data/lib/rack/attack/{blacklist.rb → safelist.rb} +2 -4
  14. data/lib/rack/attack/store_proxy.rb +13 -12
  15. data/lib/rack/attack/store_proxy/dalli_proxy.rb +2 -3
  16. data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +50 -0
  17. data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +19 -0
  18. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +35 -0
  19. data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
  20. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -24
  21. data/lib/rack/attack/throttle.rb +16 -12
  22. data/lib/rack/attack/track.rb +3 -3
  23. data/lib/rack/attack/version.rb +1 -1
  24. data/spec/acceptance/allow2ban_spec.rb +71 -0
  25. data/spec/acceptance/blocking_ip_spec.rb +38 -0
  26. data/spec/acceptance/blocking_spec.rb +41 -0
  27. data/spec/acceptance/blocking_subnet_spec.rb +44 -0
  28. data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +126 -0
  29. data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +121 -0
  30. data/spec/acceptance/cache_store_config_for_throttle_spec.rb +48 -0
  31. data/spec/acceptance/cache_store_config_with_rails_spec.rb +31 -0
  32. data/spec/acceptance/customizing_blocked_response_spec.rb +41 -0
  33. data/spec/acceptance/customizing_throttled_response_spec.rb +59 -0
  34. data/spec/acceptance/extending_request_object_spec.rb +34 -0
  35. data/spec/acceptance/fail2ban_spec.rb +76 -0
  36. data/spec/acceptance/safelisting_ip_spec.rb +48 -0
  37. data/spec/acceptance/safelisting_spec.rb +53 -0
  38. data/spec/acceptance/safelisting_subnet_spec.rb +48 -0
  39. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +19 -0
  40. data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +22 -0
  41. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +18 -0
  42. data/spec/acceptance/stores/active_support_memory_store_spec.rb +16 -0
  43. data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +18 -0
  44. data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +18 -0
  45. data/spec/acceptance/stores/active_support_redis_store_spec.rb +18 -0
  46. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +22 -0
  47. data/spec/acceptance/stores/dalli_client_spec.rb +19 -0
  48. data/spec/acceptance/stores/redis_spec.rb +20 -0
  49. data/spec/acceptance/stores/redis_store_spec.rb +18 -0
  50. data/spec/acceptance/throttling_spec.rb +159 -0
  51. data/spec/acceptance/track_spec.rb +27 -0
  52. data/spec/acceptance/track_throttle_spec.rb +53 -0
  53. data/spec/allow2ban_spec.rb +10 -9
  54. data/spec/fail2ban_spec.rb +12 -10
  55. data/spec/integration/offline_spec.rb +21 -23
  56. data/spec/rack_attack_dalli_proxy_spec.rb +0 -2
  57. data/spec/rack_attack_request_spec.rb +2 -2
  58. data/spec/rack_attack_spec.rb +53 -18
  59. data/spec/rack_attack_throttle_spec.rb +45 -13
  60. data/spec/rack_attack_track_spec.rb +11 -8
  61. data/spec/spec_helper.rb +35 -14
  62. data/spec/support/cache_store_helper.rb +82 -0
  63. metadata +161 -61
  64. data/spec/integration/rack_attack_cache_spec.rb +0 -119
@@ -1,12 +1,10 @@
1
1
  module Rack
2
2
  class Attack
3
- class Blacklist < Check
3
+ class Safelist < Check
4
4
  def initialize(name, block)
5
5
  super
6
- @type = :blacklist
6
+ @type = :safelist
7
7
  end
8
-
9
8
  end
10
9
  end
11
10
  end
12
-
@@ -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
- # RedisStore#increment needs different behavior, so detect that
8
- # (method has an arity of 2; must call #expire separately
9
- if defined?(::ActiveSupport::Cache::RedisStore) && store.is_a?(::ActiveSupport::Cache::RedisStore)
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 < SimpleDelegator
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
- self.get(key, raw: true)
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
- self.setex(key, expires_in, value, raw: true)
18
+ setex(key, expires_in, value, raw: true)
23
19
  else
24
- self.set(key, value, raw: true)
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
@@ -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 [](req)
21
- discriminator = block[req]
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(req) : period
25
- current_limit = limit.respond_to?(:call) ? limit.call(req) : limit
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
- (req.env['rack.attack.throttle_data'] ||= {})[name] = data
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
- req.env['rack.attack.matched'] = name
39
- req.env['rack.attack.match_discriminator'] = discriminator
40
- req.env['rack.attack.match_type'] = type
41
- req.env['rack.attack.match_data'] = data
42
- Rack::Attack.instrument(req)
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
@@ -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
- def_delegator :@filter, :[]
16
+ def matched_by?(request)
17
+ filter.matched_by?(request)
18
+ end
19
19
  end
20
20
  end
21
21
  end
@@ -1,5 +1,5 @@
1
1
  module Rack
2
2
  class Attack
3
- VERSION = '4.3.1'
3
+ VERSION = '5.4.2'
4
4
  end
5
5
  end
@@ -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