rack-attack 5.4.0 → 6.2.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -27
  3. data/Rakefile +3 -1
  4. data/bin/setup +8 -0
  5. data/lib/rack/attack.rb +137 -148
  6. data/lib/rack/attack/allow2ban.rb +2 -0
  7. data/lib/rack/attack/blocklist.rb +3 -1
  8. data/lib/rack/attack/cache.rb +9 -4
  9. data/lib/rack/attack/check.rb +5 -2
  10. data/lib/rack/attack/fail2ban.rb +2 -0
  11. data/lib/rack/attack/path_normalizer.rb +22 -18
  12. data/lib/rack/attack/railtie.rb +21 -0
  13. data/lib/rack/attack/request.rb +2 -0
  14. data/lib/rack/attack/safelist.rb +3 -1
  15. data/lib/rack/attack/store_proxy.rb +12 -24
  16. data/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb +39 -0
  17. data/lib/rack/attack/store_proxy/dalli_proxy.rb +27 -13
  18. data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +21 -0
  19. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +23 -9
  20. data/lib/rack/attack/store_proxy/redis_proxy.rb +16 -10
  21. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -5
  22. data/lib/rack/attack/throttle.rb +12 -8
  23. data/lib/rack/attack/track.rb +9 -6
  24. data/lib/rack/attack/version.rb +3 -1
  25. data/spec/acceptance/allow2ban_spec.rb +2 -0
  26. data/spec/acceptance/blocking_ip_spec.rb +4 -2
  27. data/spec/acceptance/blocking_spec.rb +45 -3
  28. data/spec/acceptance/blocking_subnet_spec.rb +4 -2
  29. data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +50 -39
  30. data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +38 -29
  31. data/spec/acceptance/cache_store_config_for_throttle_spec.rb +2 -0
  32. data/spec/acceptance/cache_store_config_with_rails_spec.rb +2 -0
  33. data/spec/acceptance/customizing_blocked_response_spec.rb +2 -0
  34. data/spec/acceptance/customizing_throttled_response_spec.rb +2 -0
  35. data/spec/acceptance/extending_request_object_spec.rb +2 -0
  36. data/spec/acceptance/fail2ban_spec.rb +2 -0
  37. data/spec/acceptance/rails_middleware_spec.rb +41 -0
  38. data/spec/acceptance/safelisting_ip_spec.rb +4 -2
  39. data/spec/acceptance/safelisting_spec.rb +57 -3
  40. data/spec/acceptance/safelisting_subnet_spec.rb +4 -2
  41. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +3 -23
  42. data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +20 -0
  43. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +4 -24
  44. data/spec/acceptance/stores/active_support_memory_store_spec.rb +3 -23
  45. data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +10 -24
  46. data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +9 -25
  47. data/spec/acceptance/stores/active_support_redis_store_spec.rb +4 -24
  48. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +5 -23
  49. data/spec/acceptance/stores/dalli_client_spec.rb +3 -23
  50. data/spec/acceptance/stores/redis_spec.rb +1 -23
  51. data/spec/acceptance/stores/redis_store_spec.rb +3 -23
  52. data/spec/acceptance/throttling_spec.rb +7 -5
  53. data/spec/acceptance/track_spec.rb +5 -3
  54. data/spec/acceptance/track_throttle_spec.rb +5 -3
  55. data/spec/allow2ban_spec.rb +20 -15
  56. data/spec/fail2ban_spec.rb +20 -17
  57. data/spec/integration/offline_spec.rb +3 -1
  58. data/spec/rack_attack_dalli_proxy_spec.rb +2 -0
  59. data/spec/rack_attack_instrumentation_spec.rb +42 -0
  60. data/spec/rack_attack_path_normalizer_spec.rb +4 -2
  61. data/spec/rack_attack_request_spec.rb +2 -0
  62. data/spec/rack_attack_spec.rb +38 -34
  63. data/spec/rack_attack_throttle_spec.rb +50 -19
  64. data/spec/rack_attack_track_spec.rb +12 -7
  65. data/spec/spec_helper.rb +10 -8
  66. data/spec/support/cache_store_helper.rb +27 -1
  67. metadata +48 -28
  68. 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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Attack
5
+ class Railtie < ::Rails::Railtie
6
+ initializer 'rack.attack.middleware', after: :load_config_initializers, before: :build_middleware_stack do |app|
7
+ if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("5.1")
8
+ middlewares = app.config.middleware
9
+ operations = middlewares.send(:operations) + middlewares.send(:delete_operations)
10
+
11
+ use_middleware = operations.none? do |operation|
12
+ middleware = operation[1]
13
+ middleware.include?(Rack::Attack)
14
+ end
15
+
16
+ middlewares.use(Rack::Attack) if use_middleware
17
+ end
18
+ end
19
+ end
20
+ end
21
+ 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,32 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  module StoreProxy
4
- PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
5
-
6
- ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore', 'ActiveSupport::Cache::RedisCacheStore']).freeze
7
- ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze
6
+ PROXIES = [
7
+ DalliProxy,
8
+ MemCacheStoreProxy,
9
+ RedisStoreProxy,
10
+ RedisProxy,
11
+ RedisCacheStoreProxy,
12
+ ActiveSupportRedisStoreProxy
13
+ ].freeze
8
14
 
9
15
  def self.build(store)
10
- client = unwrap_active_support_stores(store)
11
- klass = PROXIES.find { |proxy| proxy.handle?(client) }
12
- klass ? klass.new(client) : client
13
- end
14
-
15
- def self.unwrap_active_support_stores(store)
16
- # ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
17
- # so use the raw Redis::Store instead.
18
- # We also want to use the underlying Dalli client instead of ::ActiveSupport::Cache::MemCacheStore,
19
- # and the MemCache client if using Rails 3.x
20
-
21
- if store.instance_variable_defined?(:@data)
22
- client = store.instance_variable_get(:@data)
23
- end
24
-
25
- if ACTIVE_SUPPORT_WRAPPER_CLASSES.include?(store.class.to_s) && ACTIVE_SUPPORT_CLIENTS.include?(client.class.to_s)
26
- client
27
- else
28
- store
29
- end
16
+ klass = PROXIES.find { |proxy| proxy.handle?(store) }
17
+ klass ? klass.new(store) : store
30
18
  end
31
19
  end
32
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
@@ -0,0 +1,21 @@
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) &&
11
+ defined?(::ActiveSupport::Cache::MemCacheStore) &&
12
+ store.is_a?(::ActiveSupport::Cache::MemCacheStore)
13
+ end
14
+
15
+ def write(name, value, options = {})
16
+ super(name, value, options.merge!(raw: true))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -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