rack-attack 5.4.1 → 6.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -27
  3. data/Rakefile +3 -1
  4. data/lib/rack/attack.rb +138 -149
  5. data/lib/rack/attack/allow2ban.rb +2 -0
  6. data/lib/rack/attack/blocklist.rb +3 -1
  7. data/lib/rack/attack/cache.rb +9 -4
  8. data/lib/rack/attack/check.rb +5 -2
  9. data/lib/rack/attack/fail2ban.rb +2 -0
  10. data/lib/rack/attack/path_normalizer.rb +22 -18
  11. data/lib/rack/attack/railtie.rb +13 -0
  12. data/lib/rack/attack/request.rb +2 -0
  13. data/lib/rack/attack/safelist.rb +3 -1
  14. data/lib/rack/attack/store_proxy.rb +12 -14
  15. data/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb +39 -0
  16. data/lib/rack/attack/store_proxy/dalli_proxy.rb +27 -13
  17. data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +3 -1
  18. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +23 -9
  19. data/lib/rack/attack/store_proxy/redis_proxy.rb +16 -10
  20. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -5
  21. data/lib/rack/attack/throttle.rb +12 -8
  22. data/lib/rack/attack/track.rb +9 -6
  23. data/lib/rack/attack/version.rb +3 -1
  24. data/spec/acceptance/allow2ban_spec.rb +2 -0
  25. data/spec/acceptance/blocking_ip_spec.rb +4 -2
  26. data/spec/acceptance/blocking_spec.rb +45 -3
  27. data/spec/acceptance/blocking_subnet_spec.rb +4 -2
  28. data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +50 -39
  29. data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +38 -29
  30. data/spec/acceptance/cache_store_config_for_throttle_spec.rb +2 -0
  31. data/spec/acceptance/cache_store_config_with_rails_spec.rb +2 -0
  32. data/spec/acceptance/customizing_blocked_response_spec.rb +2 -0
  33. data/spec/acceptance/customizing_throttled_response_spec.rb +2 -0
  34. data/spec/acceptance/extending_request_object_spec.rb +2 -0
  35. data/spec/acceptance/fail2ban_spec.rb +2 -0
  36. data/spec/acceptance/rails_middleware_spec.rb +35 -0
  37. data/spec/acceptance/safelisting_ip_spec.rb +4 -2
  38. data/spec/acceptance/safelisting_spec.rb +57 -3
  39. data/spec/acceptance/safelisting_subnet_spec.rb +4 -2
  40. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +2 -0
  41. data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +1 -3
  42. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +2 -0
  43. data/spec/acceptance/stores/active_support_memory_store_spec.rb +2 -0
  44. data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +9 -1
  45. data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +8 -1
  46. data/spec/acceptance/stores/active_support_redis_store_spec.rb +3 -1
  47. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +5 -3
  48. data/spec/acceptance/stores/dalli_client_spec.rb +2 -0
  49. data/spec/acceptance/stores/redis_store_spec.rb +2 -0
  50. data/spec/acceptance/throttling_spec.rb +7 -5
  51. data/spec/acceptance/track_spec.rb +5 -3
  52. data/spec/acceptance/track_throttle_spec.rb +5 -3
  53. data/spec/allow2ban_spec.rb +20 -15
  54. data/spec/fail2ban_spec.rb +20 -17
  55. data/spec/integration/offline_spec.rb +3 -1
  56. data/spec/rack_attack_dalli_proxy_spec.rb +2 -0
  57. data/spec/rack_attack_instrumentation_spec.rb +42 -0
  58. data/spec/rack_attack_path_normalizer_spec.rb +4 -2
  59. data/spec/rack_attack_request_spec.rb +2 -0
  60. data/spec/rack_attack_spec.rb +38 -34
  61. data/spec/rack_attack_throttle_spec.rb +50 -19
  62. data/spec/rack_attack_track_spec.rb +12 -7
  63. data/spec/spec_helper.rb +12 -8
  64. data/spec/support/cache_store_helper.rb +2 -0
  65. metadata +44 -28
  66. data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +0 -50
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Allow2Ban < Fail2Ban
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Blocklist < Check
4
- def initialize(name, block)
6
+ def initialize(name = nil, &block)
5
7
  super
6
8
  @type = :blocklist
7
9
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Cache
@@ -27,7 +29,7 @@ module Rack
27
29
  end
28
30
 
29
31
  def write(unprefixed_key, value, expires_in)
30
- store.write("#{prefix}:#{unprefixed_key}", value, :expires_in => expires_in)
32
+ store.write("#{prefix}:#{unprefixed_key}", value, expires_in: expires_in)
31
33
  end
32
34
 
33
35
  def reset_count(unprefixed_key, period)
@@ -52,13 +54,13 @@ module Rack
52
54
  enforce_store_presence!
53
55
  enforce_store_method_presence!(:increment)
54
56
 
55
- result = store.increment(key, 1, :expires_in => expires_in)
57
+ result = store.increment(key, 1, expires_in: expires_in)
56
58
 
57
59
  # NB: Some stores return nil when incrementing uninitialized values
58
60
  if result.nil?
59
61
  enforce_store_method_presence!(:write)
60
62
 
61
- store.write(key, 1, :expires_in => expires_in)
63
+ store.write(key, 1, expires_in: expires_in)
62
64
  end
63
65
  result || 1
64
66
  end
@@ -71,7 +73,10 @@ module Rack
71
73
 
72
74
  def enforce_store_method_presence!(method_name)
73
75
  if !store.respond_to?(method_name)
74
- raise Rack::Attack::MisconfiguredStoreError, "Store needs to respond to ##{method_name}"
76
+ raise(
77
+ Rack::Attack::MisconfiguredStoreError,
78
+ "Configured store #{store.class.name} doesn't respond to ##{method_name} method"
79
+ )
75
80
  end
76
81
  end
77
82
  end
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Check
4
6
  attr_reader :name, :block, :type
5
- def initialize(name, options = {}, block)
6
- @name, @block = name, block
7
+ def initialize(name, options = {}, &block)
8
+ @name = name
9
+ @block = block
7
10
  @type = options.fetch(:type, nil)
8
11
  end
9
12
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Fail2Ban
@@ -1,22 +1,26 @@
1
- class Rack::Attack
2
- # When using Rack::Attack with a Rails app, developers expect the request path
3
- # to be normalized. In particular, trailing slashes are stripped.
4
- # (See https://git.io/v0rrR for implementation.)
5
- #
6
- # Look for an ActionDispatch utility class that Rails folks would expect
7
- # to normalize request paths. If unavailable, use a fallback class that
8
- # doesn't normalize the path (as a non-Rails rack app developer expects).
1
+ # frozen_string_literal: true
9
2
 
10
- module FallbackPathNormalizer
11
- def self.normalize_path(path)
12
- path
3
+ module Rack
4
+ class Attack
5
+ # When using Rack::Attack with a Rails app, developers expect the request path
6
+ # to be normalized. In particular, trailing slashes are stripped.
7
+ # (See https://git.io/v0rrR for implementation.)
8
+ #
9
+ # Look for an ActionDispatch utility class that Rails folks would expect
10
+ # to normalize request paths. If unavailable, use a fallback class that
11
+ # doesn't normalize the path (as a non-Rails rack app developer expects).
12
+
13
+ module FallbackPathNormalizer
14
+ def self.normalize_path(path)
15
+ path
16
+ end
13
17
  end
14
- end
15
18
 
16
- PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils)
17
- # For Rails apps
18
- ::ActionDispatch::Journey::Router::Utils
19
- else
20
- FallbackPathNormalizer
21
- end
19
+ PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils)
20
+ # For Rails apps
21
+ ::ActionDispatch::Journey::Router::Utils
22
+ else
23
+ FallbackPathNormalizer
24
+ end
25
+ end
22
26
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Attack
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "rack-attack.middleware" do |app|
7
+ if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("5.1")
8
+ app.middleware.use(Rack::Attack)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Rack::Attack::Request is the same as ::Rack::Request by default.
2
4
  #
3
5
  # This is a safe place to add custom helper methods to the request object
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Safelist < Check
4
- def initialize(name, block)
6
+ def initialize(name = nil, &block)
5
7
  super
6
8
  @type = :safelist
7
9
  end
@@ -1,22 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  module StoreProxy
4
- PROXIES = [DalliProxy, MemCacheStoreProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
6
+ PROXIES = [
7
+ DalliProxy,
8
+ MemCacheStoreProxy,
9
+ RedisStoreProxy,
10
+ RedisProxy,
11
+ RedisCacheStoreProxy,
12
+ ActiveSupportRedisStoreProxy
13
+ ].freeze
5
14
 
6
15
  def self.build(store)
7
- client = unwrap_active_support_stores(store)
8
- klass = PROXIES.find { |proxy| proxy.handle?(client) }
9
- klass ? klass.new(client) : client
10
- end
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
16
+ klass = PROXIES.find { |proxy| proxy.handle?(store) }
17
+ klass ? klass.new(store) : store
20
18
  end
21
19
  end
22
20
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module Rack
6
+ class Attack
7
+ module StoreProxy
8
+ class ActiveSupportRedisStoreProxy < SimpleDelegator
9
+ def self.handle?(store)
10
+ defined?(::Redis) &&
11
+ defined?(::ActiveSupport::Cache::RedisStore) &&
12
+ store.is_a?(::ActiveSupport::Cache::RedisStore)
13
+ end
14
+
15
+ def increment(name, amount = 1, options = {})
16
+ # #increment ignores options[:expires_in].
17
+ #
18
+ # So in order to workaround this we use #write (which sets expiration) to initialize
19
+ # the counter. After that we continue using the original #increment.
20
+ if options[:expires_in] && !read(name)
21
+ write(name, amount, options)
22
+
23
+ amount
24
+ else
25
+ super
26
+ end
27
+ end
28
+
29
+ def read(name, options = {})
30
+ super(name, options.merge!(raw: true))
31
+ end
32
+
33
+ def write(name, value, options = {})
34
+ super(name, value, options.merge!(raw: true))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'delegate'
2
4
 
3
5
  module Rack
@@ -22,31 +24,35 @@ module Rack
22
24
  end
23
25
 
24
26
  def read(key)
25
- with do |client|
26
- client.get(key)
27
+ rescuing do
28
+ with do |client|
29
+ client.get(key)
30
+ end
27
31
  end
28
- rescue Dalli::DalliError
29
32
  end
30
33
 
31
34
  def write(key, value, options = {})
32
- with do |client|
33
- client.set(key, value, options.fetch(:expires_in, 0), raw: true)
35
+ rescuing do
36
+ with do |client|
37
+ client.set(key, value, options.fetch(:expires_in, 0), raw: true)
38
+ end
34
39
  end
35
- rescue Dalli::DalliError
36
40
  end
37
41
 
38
42
  def increment(key, amount, options = {})
39
- with do |client|
40
- client.incr(key, amount, options.fetch(:expires_in, 0), amount)
43
+ rescuing do
44
+ with do |client|
45
+ client.incr(key, amount, options.fetch(:expires_in, 0), amount)
46
+ end
41
47
  end
42
- rescue Dalli::DalliError
43
48
  end
44
49
 
45
50
  def delete(key)
46
- with do |client|
47
- client.delete(key)
51
+ rescuing do
52
+ with do |client|
53
+ client.delete(key)
54
+ end
48
55
  end
49
- rescue Dalli::DalliError
50
56
  end
51
57
 
52
58
  private
@@ -54,10 +60,18 @@ module Rack
54
60
  def stub_with_if_missing
55
61
  unless __getobj__.respond_to?(:with)
56
62
  class << self
57
- def with; yield __getobj__; end
63
+ def with
64
+ yield __getobj__
65
+ end
58
66
  end
59
67
  end
60
68
  end
69
+
70
+ def rescuing
71
+ yield
72
+ rescue Dalli::DalliError
73
+ nil
74
+ end
61
75
  end
62
76
  end
63
77
  end
@@ -7,7 +7,9 @@ module Rack
7
7
  module StoreProxy
8
8
  class MemCacheStoreProxy < SimpleDelegator
9
9
  def self.handle?(store)
10
- defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) && store.is_a?(::ActiveSupport::Cache::MemCacheStore)
10
+ defined?(::Dalli) &&
11
+ defined?(::ActiveSupport::Cache::MemCacheStore) &&
12
+ store.is_a?(::ActiveSupport::Cache::MemCacheStore)
11
13
  end
12
14
 
13
15
  def write(name, value, options = {})
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'delegate'
2
4
 
3
5
  module Rack
@@ -5,7 +7,7 @@ module Rack
5
7
  module StoreProxy
6
8
  class RedisCacheStoreProxy < SimpleDelegator
7
9
  def self.handle?(store)
8
- defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
10
+ store.class.name == "ActiveSupport::Cache::RedisCacheStore"
9
11
  end
10
12
 
11
13
  def increment(name, amount = 1, options = {})
@@ -13,21 +15,33 @@ module Rack
13
15
  #
14
16
  # So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize
15
17
  # the counter. After that we continue using the original RedisCacheStore#increment.
16
- if options[:expires_in] && !read(name)
17
- write(name, amount, options)
18
+ rescuing do
19
+ if options[:expires_in] && !read(name)
20
+ write(name, amount, options)
18
21
 
19
- amount
20
- else
21
- super
22
+ amount
23
+ else
24
+ super
25
+ end
22
26
  end
23
27
  end
24
28
 
25
- def read(name, options = {})
26
- super(name, options.merge!(raw: true))
29
+ def read(*_args)
30
+ rescuing { super }
27
31
  end
28
32
 
29
33
  def write(name, value, options = {})
30
- super(name, value, options.merge!(raw: true))
34
+ rescuing do
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
31
45
  end
32
46
  end
33
47
  end
@@ -19,34 +19,40 @@ module Rack
19
19
  end
20
20
 
21
21
  def read(key)
22
- get(key)
23
- rescue Redis::BaseError
22
+ rescuing { get(key) }
24
23
  end
25
24
 
26
25
  def write(key, value, options = {})
27
26
  if (expires_in = options[:expires_in])
28
- setex(key, expires_in, value)
27
+ rescuing { setex(key, expires_in, value) }
29
28
  else
30
- set(key, value)
29
+ rescuing { set(key, value) }
31
30
  end
32
- rescue Redis::BaseError
33
31
  end
34
32
 
35
33
  def increment(key, amount, options = {})
36
34
  count = nil
37
35
 
38
- pipelined do
39
- count = incrby(key, amount)
40
- expire(key, options[:expires_in]) if options[:expires_in]
36
+ rescuing do
37
+ pipelined do
38
+ count = incrby(key, amount)
39
+ expire(key, options[:expires_in]) if options[:expires_in]
40
+ end
41
41
  end
42
42
 
43
43
  count.value if count
44
- rescue Redis::BaseError
45
44
  end
46
45
 
47
46
  def delete(key, _options = {})
48
- del(key)
47
+ rescuing { del(key) }
48
+ end
49
+
50
+ private
51
+
52
+ def rescuing
53
+ yield
49
54
  rescue Redis::BaseError
55
+ nil
50
56
  end
51
57
  end
52
58
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'delegate'
2
4
 
3
5
  module Rack
@@ -9,17 +11,15 @@ module Rack
9
11
  end
10
12
 
11
13
  def read(key)
12
- get(key, raw: true)
13
- rescue Redis::BaseError
14
+ rescuing { get(key, raw: true) }
14
15
  end
15
16
 
16
17
  def write(key, value, options = {})
17
18
  if (expires_in = options[:expires_in])
18
- setex(key, expires_in, value, raw: true)
19
+ rescuing { setex(key, expires_in, value, raw: true) }
19
20
  else
20
- set(key, value, raw: true)
21
+ rescuing { set(key, value, raw: true) }
21
22
  end
22
- rescue Redis::BaseError
23
23
  end
24
24
  end
25
25
  end