rack-attack 5.4.2 → 6.0.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -23
  3. data/Rakefile +3 -1
  4. data/lib/rack/attack.rb +46 -70
  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 +5 -3
  8. data/lib/rack/attack/check.rb +3 -1
  9. data/lib/rack/attack/fail2ban.rb +2 -0
  10. data/lib/rack/attack/path_normalizer.rb +2 -0
  11. data/lib/rack/attack/request.rb +2 -0
  12. data/lib/rack/attack/safelist.rb +3 -1
  13. data/lib/rack/attack/store_proxy.rb +12 -14
  14. data/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb +37 -0
  15. data/lib/rack/attack/store_proxy/dalli_proxy.rb +27 -13
  16. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +2 -4
  17. data/lib/rack/attack/store_proxy/redis_proxy.rb +16 -10
  18. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -5
  19. data/lib/rack/attack/throttle.rb +8 -6
  20. data/lib/rack/attack/track.rb +5 -3
  21. data/lib/rack/attack/version.rb +3 -1
  22. data/spec/acceptance/allow2ban_spec.rb +2 -0
  23. data/spec/acceptance/blocking_ip_spec.rb +4 -2
  24. data/spec/acceptance/blocking_spec.rb +45 -3
  25. data/spec/acceptance/blocking_subnet_spec.rb +4 -2
  26. data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +8 -12
  27. data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +8 -12
  28. data/spec/acceptance/cache_store_config_for_throttle_spec.rb +2 -0
  29. data/spec/acceptance/cache_store_config_with_rails_spec.rb +2 -0
  30. data/spec/acceptance/customizing_blocked_response_spec.rb +2 -0
  31. data/spec/acceptance/customizing_throttled_response_spec.rb +2 -0
  32. data/spec/acceptance/extending_request_object_spec.rb +2 -0
  33. data/spec/acceptance/fail2ban_spec.rb +2 -0
  34. data/spec/acceptance/safelisting_ip_spec.rb +4 -2
  35. data/spec/acceptance/safelisting_spec.rb +57 -3
  36. data/spec/acceptance/safelisting_subnet_spec.rb +4 -2
  37. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +2 -0
  38. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +2 -0
  39. data/spec/acceptance/stores/active_support_memory_store_spec.rb +2 -0
  40. data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +2 -0
  41. data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +2 -0
  42. data/spec/acceptance/stores/active_support_redis_store_spec.rb +3 -1
  43. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +2 -0
  44. data/spec/acceptance/stores/dalli_client_spec.rb +2 -0
  45. data/spec/acceptance/stores/redis_store_spec.rb +2 -0
  46. data/spec/acceptance/throttling_spec.rb +7 -5
  47. data/spec/acceptance/track_spec.rb +5 -3
  48. data/spec/acceptance/track_throttle_spec.rb +5 -3
  49. data/spec/allow2ban_spec.rb +3 -1
  50. data/spec/fail2ban_spec.rb +3 -1
  51. data/spec/integration/offline_spec.rb +3 -1
  52. data/spec/rack_attack_dalli_proxy_spec.rb +2 -0
  53. data/spec/rack_attack_instrumentation_spec.rb +42 -0
  54. data/spec/rack_attack_path_normalizer_spec.rb +2 -0
  55. data/spec/rack_attack_request_spec.rb +2 -0
  56. data/spec/rack_attack_spec.rb +2 -21
  57. data/spec/rack_attack_throttle_spec.rb +10 -8
  58. data/spec/rack_attack_track_spec.rb +4 -2
  59. data/spec/spec_helper.rb +5 -4
  60. data/spec/support/cache_store_helper.rb +2 -0
  61. metadata +21 -14
  62. data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +0 -50
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Rack::Attack
2
4
  # When using Rack::Attack with a Rails app, developers expect the request path
3
5
  # to be normalized. In particular, trailing slashes are stripped.
@@ -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,37 @@
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) && defined?(::ActiveSupport::Cache::RedisStore) && store.is_a?(::ActiveSupport::Cache::RedisStore)
11
+ end
12
+
13
+ def increment(name, amount = 1, options = {})
14
+ # #increment ignores options[:expires_in].
15
+ #
16
+ # So in order to workaround this we use #write (which sets expiration) to initialize
17
+ # the counter. After that we continue using the original #increment.
18
+ if options[:expires_in] && !read(name)
19
+ write(name, amount, options)
20
+
21
+ amount
22
+ else
23
+ super
24
+ end
25
+ end
26
+
27
+ def read(name, options = {})
28
+ super(name, options.merge!(raw: true))
29
+ end
30
+
31
+ def write(name, value, options = {})
32
+ super(name, value, options.merge!(raw: true))
33
+ end
34
+ end
35
+ end
36
+ end
37
+ 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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'delegate'
2
4
 
3
5
  module Rack
@@ -22,10 +24,6 @@ module Rack
22
24
  end
23
25
  end
24
26
 
25
- def read(name, options = {})
26
- super(name, options.merge!(raw: true))
27
- end
28
-
29
27
  def write(name, value, options = {})
30
28
  super(name, value, options.merge!(raw: true))
31
29
  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
@@ -1,13 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Throttle
4
6
  MANDATORY_OPTIONS = [:limit, :period].freeze
5
7
 
6
8
  attr_reader :name, :limit, :period, :block, :type
7
- def initialize(name, options, block)
9
+ def initialize(name, options, &block)
8
10
  @name, @block = name, block
9
11
  MANDATORY_OPTIONS.each do |opt|
10
- raise ArgumentError.new("Must pass #{opt.inspect} option") unless options[opt]
12
+ raise ArgumentError, "Must pass #{opt.inspect} option" unless options[opt]
11
13
  end
12
14
  @limit = options[:limit]
13
15
  @period = options[:period].respond_to?(:call) ? options[:period] : options[:period].to_i
@@ -29,10 +31,10 @@ module Rack
29
31
  epoch_time = cache.last_epoch_time
30
32
 
31
33
  data = {
32
- :count => count,
33
- :period => current_period,
34
- :limit => current_limit,
35
- :epoch_time => epoch_time
34
+ count: count,
35
+ period: current_period,
36
+ limit: current_limit,
37
+ epoch_time: epoch_time
36
38
  }
37
39
 
38
40
  (request.env['rack.attack.throttle_data'] ||= {})[name] = data
@@ -1,15 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
5
  class Track
4
6
  attr_reader :filter
5
7
 
6
- def initialize(name, options = {}, block)
8
+ def initialize(name, options = {}, &block)
7
9
  options[:type] = :track
8
10
 
9
11
  if options[:limit] && options[:period]
10
- @filter = Throttle.new(name, options, block)
12
+ @filter = Throttle.new(name, options, &block)
11
13
  else
12
- @filter = Check.new(name, options, block)
14
+ @filter = Check.new(name, options, &block)
13
15
  end
14
16
  end
15
17
 
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class Attack
3
- VERSION = '5.4.2'
5
+ VERSION = '6.0.0'
4
6
  end
5
7
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
  require "timecop"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  describe "Blocking an IP" do
@@ -21,9 +23,9 @@ describe "Blocking an IP" do
21
23
  notified = false
22
24
  notification_type = nil
23
25
 
24
- ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
26
+ ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload|
25
27
  notified = true
26
- notification_type = request.env["rack.attack.match_type"]
28
+ notification_type = payload[:request].env["rack.attack.match_type"]
27
29
  end
28
30
 
29
31
  get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
@@ -1,6 +1,48 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  describe "#blocklist" do
6
+ before do
7
+ Rack::Attack.blocklist do |request|
8
+ request.ip == "1.2.3.4"
9
+ end
10
+ end
11
+
12
+ it "forbids request if blocklist condition is true" do
13
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
14
+
15
+ assert_equal 403, last_response.status
16
+ end
17
+
18
+ it "succeeds if blocklist condition is false" do
19
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
20
+
21
+ assert_equal 200, last_response.status
22
+ end
23
+
24
+ it "notifies when the request is blocked" do
25
+ notification_matched = nil
26
+ notification_type = nil
27
+
28
+ ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload|
29
+ notification_matched = payload[:request].env["rack.attack.matched"]
30
+ notification_type = payload[:request].env["rack.attack.match_type"]
31
+ end
32
+
33
+ get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
34
+
35
+ assert_nil notification_matched
36
+ assert_nil notification_type
37
+
38
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
39
+
40
+ assert_nil notification_matched
41
+ assert_equal :blocklist, notification_type
42
+ end
43
+ end
44
+
45
+ describe "#blocklist with name" do
4
46
  before do
5
47
  Rack::Attack.blocklist("block 1.2.3.4") do |request|
6
48
  request.ip == "1.2.3.4"
@@ -23,9 +65,9 @@ describe "#blocklist" do
23
65
  notification_matched = nil
24
66
  notification_type = nil
25
67
 
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"]
68
+ ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload|
69
+ notification_matched = payload[:request].env["rack.attack.matched"]
70
+ notification_type = payload[:request].env["rack.attack.match_type"]
29
71
  end
30
72
 
31
73
  get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  describe "Blocking an IP subnet" do
@@ -27,9 +29,9 @@ describe "Blocking an IP subnet" do
27
29
  notified = false
28
30
  notification_type = nil
29
31
 
30
- ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
32
+ ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload|
31
33
  notified = true
32
- notification_type = request.env["rack.attack.match_type"]
34
+ notification_type = payload[:request].env["rack.attack.match_type"]
33
35
  end
34
36
 
35
37
  get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
  require "minitest/stub_const"
3
5
 
@@ -20,11 +22,9 @@ describe "Cache store config when using allow2ban" do
20
22
  raised_exception = nil
21
23
 
22
24
  fake_store_class = Class.new do
23
- def write(key, value)
24
- end
25
+ def write(key, value); end
25
26
 
26
- def increment(key, count, options = {})
27
- end
27
+ def increment(key, count, options = {}); end
28
28
  end
29
29
 
30
30
  Object.stub_const(:FakeStore, fake_store_class) do
@@ -42,11 +42,9 @@ describe "Cache store config when using allow2ban" do
42
42
  raised_exception = nil
43
43
 
44
44
  fake_store_class = Class.new do
45
- def read(key)
46
- end
45
+ def read(key); end
47
46
 
48
- def increment(key, count, options = {})
49
- end
47
+ def increment(key, count, options = {}); end
50
48
  end
51
49
 
52
50
  Object.stub_const(:FakeStore, fake_store_class) do
@@ -64,11 +62,9 @@ describe "Cache store config when using allow2ban" do
64
62
  raised_exception = nil
65
63
 
66
64
  fake_store_class = Class.new do
67
- def read(key)
68
- end
65
+ def read(key); end
69
66
 
70
- def write(key, value)
71
- end
67
+ def write(key, value); end
72
68
  end
73
69
 
74
70
  Object.stub_const(:FakeStore, fake_store_class) do