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
data/Rakefile CHANGED
@@ -2,6 +2,9 @@ require "rubygems"
2
2
  require "bundler/setup"
3
3
  require 'bundler/gem_tasks'
4
4
  require 'rake/testtask'
5
+ require "rubocop/rake_task"
6
+
7
+ RuboCop::RakeTask.new
5
8
 
6
9
  namespace :test do
7
10
  Rake::TestTask.new(:units) do |t|
@@ -11,9 +14,14 @@ namespace :test do
11
14
  Rake::TestTask.new(:integration) do |t|
12
15
  t.pattern = "spec/integration/*_spec.rb"
13
16
  end
17
+
18
+ Rake::TestTask.new(:acceptance) do |t|
19
+ t.pattern = "spec/acceptance/**/*_spec.rb"
20
+ end
14
21
  end
15
22
 
16
- desc 'Run tests'
17
- task :test => %w[test:units test:integration]
23
+ Rake::TestTask.new(:test) do |t|
24
+ t.pattern = "spec/**/*_spec.rb"
25
+ end
18
26
 
19
- task :default => :test
27
+ task :default => [:rubocop, :test]
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/rack/attack.rb CHANGED
@@ -1,31 +1,60 @@
1
1
  require 'rack'
2
2
  require 'forwardable'
3
+ require 'rack/attack/path_normalizer'
4
+ require 'rack/attack/request'
5
+ require "ipaddr"
3
6
 
4
7
  class Rack::Attack
5
- autoload :Cache, 'rack/attack/cache'
6
- autoload :PathNormalizer, 'rack/attack/path_normalizer'
7
- autoload :Check, 'rack/attack/check'
8
- autoload :Throttle, 'rack/attack/throttle'
9
- autoload :Whitelist, 'rack/attack/whitelist'
10
- autoload :Blacklist, 'rack/attack/blacklist'
11
- autoload :Track, 'rack/attack/track'
12
- autoload :StoreProxy, 'rack/attack/store_proxy'
13
- autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
14
- autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
15
- autoload :Fail2Ban, 'rack/attack/fail2ban'
16
- autoload :Allow2Ban, 'rack/attack/allow2ban'
17
- autoload :Request, 'rack/attack/request'
8
+ class MisconfiguredStoreError < StandardError; end
9
+ class MissingStoreError < StandardError; end
10
+
11
+ autoload :Cache, 'rack/attack/cache'
12
+ autoload :Check, 'rack/attack/check'
13
+ autoload :Throttle, 'rack/attack/throttle'
14
+ autoload :Safelist, 'rack/attack/safelist'
15
+ autoload :Blocklist, 'rack/attack/blocklist'
16
+ autoload :Track, 'rack/attack/track'
17
+ autoload :StoreProxy, 'rack/attack/store_proxy'
18
+ autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
19
+ autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
20
+ autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy'
21
+ autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
22
+ autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
23
+ autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
24
+ autoload :Fail2Ban, 'rack/attack/fail2ban'
25
+ autoload :Allow2Ban, 'rack/attack/allow2ban'
18
26
 
19
27
  class << self
28
+ attr_accessor :notifier, :blocklisted_response, :throttled_response
20
29
 
21
- attr_accessor :notifier, :blacklisted_response, :throttled_response
30
+ def safelist(name, &block)
31
+ self.safelists[name] = Safelist.new(name, block)
32
+ end
22
33
 
23
34
  def whitelist(name, &block)
24
- self.whitelists[name] = Whitelist.new(name, block)
35
+ warn "[DEPRECATION] 'Rack::Attack.whitelist' is deprecated. Please use 'safelist' instead."
36
+ safelist(name, &block)
37
+ end
38
+
39
+ def blocklist(name, &block)
40
+ self.blocklists[name] = Blocklist.new(name, block)
41
+ end
42
+
43
+ def blocklist_ip(ip_address)
44
+ @ip_blocklists ||= []
45
+ ip_blocklist_proc = lambda { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
46
+ @ip_blocklists << Blocklist.new(nil, ip_blocklist_proc)
47
+ end
48
+
49
+ def safelist_ip(ip_address)
50
+ @ip_safelists ||= []
51
+ ip_safelist_proc = lambda { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
52
+ @ip_safelists << Safelist.new(nil, ip_safelist_proc)
25
53
  end
26
54
 
27
55
  def blacklist(name, &block)
28
- self.blacklists[name] = Blacklist.new(name, block)
56
+ warn "[DEPRECATION] 'Rack::Attack.blacklist' is deprecated. Please use 'blocklist' instead."
57
+ blocklist(name, &block)
29
58
  end
30
59
 
31
60
  def throttle(name, options, &block)
@@ -36,55 +65,102 @@ class Rack::Attack
36
65
  self.tracks[name] = Track.new(name, options, block)
37
66
  end
38
67
 
39
- def whitelists; @whitelists ||= {}; end
40
- def blacklists; @blacklists ||= {}; end
68
+ def safelists; @safelists ||= {}; end
69
+
70
+ def blocklists; @blocklists ||= {}; end
71
+
41
72
  def throttles; @throttles ||= {}; end
73
+
42
74
  def tracks; @tracks ||= {}; end
43
75
 
44
- def whitelisted?(req)
45
- whitelists.any? do |name, whitelist|
46
- whitelist[req]
47
- end
76
+ def whitelists
77
+ warn "[DEPRECATION] 'Rack::Attack.whitelists' is deprecated. Please use 'safelists' instead."
78
+ safelists
48
79
  end
49
80
 
50
- def blacklisted?(req)
51
- blacklists.any? do |name, blacklist|
52
- blacklist[req]
53
- end
81
+ def blacklists
82
+ warn "[DEPRECATION] 'Rack::Attack.blacklists' is deprecated. Please use 'blocklists' instead."
83
+ blocklists
84
+ end
85
+
86
+ def safelisted?(request)
87
+ ip_safelists.any? { |safelist| safelist.matched_by?(request) } ||
88
+ safelists.any? { |_name, safelist| safelist.matched_by?(request) }
89
+ end
90
+
91
+ def whitelisted?(request)
92
+ warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead."
93
+ safelisted?(request)
94
+ end
95
+
96
+ def blocklisted?(request)
97
+ ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
98
+ blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
99
+ end
100
+
101
+ def blacklisted?(request)
102
+ warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead."
103
+ blocklisted?(request)
54
104
  end
55
105
 
56
- def throttled?(req)
57
- throttles.any? do |name, throttle|
58
- throttle[req]
106
+ def throttled?(request)
107
+ throttles.any? do |_name, throttle|
108
+ throttle.matched_by?(request)
59
109
  end
60
110
  end
61
111
 
62
- def tracked?(req)
63
- tracks.each_value do |tracker|
64
- tracker[req]
112
+ def tracked?(request)
113
+ tracks.each_value do |track|
114
+ track.matched_by?(request)
65
115
  end
66
116
  end
67
117
 
68
- def instrument(req)
69
- notifier.instrument('rack.attack', req) if notifier
118
+ def instrument(request)
119
+ notifier.instrument('rack.attack', request) if notifier
70
120
  end
71
121
 
72
122
  def cache
73
123
  @cache ||= Cache.new
74
124
  end
75
125
 
126
+ def clear_configuration
127
+ @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {}
128
+ @ip_blocklists = []
129
+ @ip_safelists = []
130
+ end
131
+
76
132
  def clear!
77
- @whitelists, @blacklists, @throttles, @tracks = {}, {}, {}, {}
133
+ warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
134
+ clear_configuration
78
135
  end
79
136
 
137
+ def blacklisted_response=(res)
138
+ warn "[DEPRECATION] 'Rack::Attack.blacklisted_response=' is deprecated. Please use 'blocklisted_response=' instead."
139
+ self.blocklisted_response = res
140
+ end
141
+
142
+ def blacklisted_response
143
+ warn "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead."
144
+ blocklisted_response
145
+ end
146
+
147
+ private
148
+
149
+ def ip_blocklists
150
+ @ip_blocklists ||= []
151
+ end
152
+
153
+ def ip_safelists
154
+ @ip_safelists ||= []
155
+ end
80
156
  end
81
157
 
82
158
  # Set defaults
83
159
  @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
84
- @blacklisted_response = lambda {|env| [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] }
85
- @throttled_response = lambda {|env|
160
+ @blocklisted_response = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] }
161
+ @throttled_response = lambda { |env|
86
162
  retry_after = (env['rack.attack.match_data'] || {})[:period]
87
- [429, {'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s}, ["Retry later\n"]]
163
+ [429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]]
88
164
  }
89
165
 
90
166
  def initialize(app)
@@ -93,23 +169,20 @@ class Rack::Attack
93
169
 
94
170
  def call(env)
95
171
  env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
96
- req = Rack::Attack::Request.new(env)
172
+ request = Rack::Attack::Request.new(env)
97
173
 
98
- if whitelisted?(req)
174
+ if safelisted?(request)
99
175
  @app.call(env)
100
- elsif blacklisted?(req)
101
- self.class.blacklisted_response.call(env)
102
- elsif throttled?(req)
176
+ elsif blocklisted?(request)
177
+ self.class.blocklisted_response.call(env)
178
+ elsif throttled?(request)
103
179
  self.class.throttled_response.call(env)
104
180
  else
105
- tracked?(req)
181
+ tracked?(request)
106
182
  @app.call(env)
107
183
  end
108
184
  end
109
185
 
110
186
  extend Forwardable
111
- def_delegators self, :whitelisted?,
112
- :blacklisted?,
113
- :throttled?,
114
- :tracked?
187
+ def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked?
115
188
  end
@@ -3,11 +3,12 @@ module Rack
3
3
  class Allow2Ban < Fail2Ban
4
4
  class << self
5
5
  protected
6
+
6
7
  def key_prefix
7
8
  'allow2ban'
8
9
  end
9
10
 
10
- # everything the same here except we return only return true
11
+ # everything is the same here except we only return true
11
12
  # (blocking the request) if they have tripped the limit.
12
13
  def fail!(discriminator, bantime, findtime, maxretry)
13
14
  count = cache.count("#{key_prefix}:count:#{discriminator}", findtime)
@@ -1,11 +1,10 @@
1
1
  module Rack
2
2
  class Attack
3
- class Whitelist < Check
3
+ class Blocklist < Check
4
4
  def initialize(name, block)
5
5
  super
6
- @type = :whitelist
6
+ @type = :blocklist
7
7
  end
8
-
9
8
  end
10
9
  end
11
10
  end
@@ -1,8 +1,8 @@
1
1
  module Rack
2
2
  class Attack
3
3
  class Cache
4
-
5
4
  attr_accessor :prefix
5
+ attr_reader :last_epoch_time
6
6
 
7
7
  def initialize
8
8
  self.store = ::Rails.cache if defined?(::Rails.cache)
@@ -20,6 +20,9 @@ module Rack
20
20
  end
21
21
 
22
22
  def read(unprefixed_key)
23
+ enforce_store_presence!
24
+ enforce_store_method_presence!(:read)
25
+
23
26
  store.read("#{prefix}:#{unprefixed_key}")
24
27
  end
25
28
 
@@ -39,22 +42,38 @@ module Rack
39
42
  private
40
43
 
41
44
  def key_and_expiry(unprefixed_key, period)
42
- epoch_time = Time.now.to_i
43
- # Add 1 to expires_in to avoid timing error: http://git.io/i1PHXA
44
- expires_in = (period - (epoch_time % period) + 1).to_i
45
- ["#{prefix}:#{(epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
45
+ @last_epoch_time = Time.now.to_i
46
+ # Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA
47
+ expires_in = (period - (@last_epoch_time % period) + 1).to_i
48
+ ["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
46
49
  end
47
50
 
48
51
  def do_count(key, expires_in)
52
+ enforce_store_presence!
53
+ enforce_store_method_presence!(:increment)
54
+
49
55
  result = store.increment(key, 1, :expires_in => expires_in)
50
56
 
51
57
  # NB: Some stores return nil when incrementing uninitialized values
52
58
  if result.nil?
59
+ enforce_store_method_presence!(:write)
60
+
53
61
  store.write(key, 1, :expires_in => expires_in)
54
62
  end
55
63
  result || 1
56
64
  end
57
65
 
66
+ def enforce_store_presence!
67
+ if store.nil?
68
+ raise Rack::Attack::MissingStoreError
69
+ end
70
+ end
71
+
72
+ def enforce_store_method_presence!(method_name)
73
+ if !store.respond_to?(method_name)
74
+ raise Rack::Attack::MisconfiguredStoreError, "Configured store #{store.class.name} doesn't respond to ##{method_name} method"
75
+ end
76
+ end
58
77
  end
59
78
  end
60
79
  end
@@ -7,17 +7,15 @@ module Rack
7
7
  @type = options.fetch(:type, nil)
8
8
  end
9
9
 
10
- def [](req)
11
- block[req].tap {|match|
10
+ def matched_by?(request)
11
+ block.call(request).tap do |match|
12
12
  if match
13
- req.env["rack.attack.matched"] = name
14
- req.env["rack.attack.match_type"] = type
15
- Rack::Attack.instrument(req)
13
+ request.env["rack.attack.matched"] = name
14
+ request.env["rack.attack.match_type"] = type
15
+ Rack::Attack.instrument(request)
16
16
  end
17
- }
17
+ end
18
18
  end
19
-
20
19
  end
21
20
  end
22
21
  end
23
-
@@ -8,7 +8,7 @@ module Rack
8
8
  maxretry = options[:maxretry] or raise ArgumentError, "Must pass maxretry option"
9
9
 
10
10
  if banned?(discriminator)
11
- # Return true for blacklist
11
+ # Return true for blocklist
12
12
  true
13
13
  elsif yield
14
14
  fail!(discriminator, bantime, findtime, maxretry)
@@ -27,6 +27,7 @@ module Rack
27
27
  end
28
28
 
29
29
  protected
30
+
30
31
  def key_prefix
31
32
  'fail2ban'
32
33
  end
@@ -40,8 +41,8 @@ module Rack
40
41
  true
41
42
  end
42
43
 
43
-
44
44
  private
45
+
45
46
  def ban!(discriminator, bantime)
46
47
  cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime)
47
48
  end
@@ -1,8 +1,7 @@
1
1
  class Rack::Attack
2
-
3
2
  # When using Rack::Attack with a Rails app, developers expect the request path
4
3
  # to be normalized. In particular, trailing slashes are stripped.
5
- # (See http://git.io/v0rrR for implementation.)
4
+ # (See https://git.io/v0rrR for implementation.)
6
5
  #
7
6
  # Look for an ActionDispatch utility class that Rails folks would expect
8
7
  # to normalize request paths. If unavailable, use a fallback class that
@@ -15,13 +14,9 @@ class Rack::Attack
15
14
  end
16
15
 
17
16
  PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils)
18
- # For Rails 4+ apps
19
- ::ActionDispatch::Journey::Router::Utils
20
- elsif defined?(::Journey::Router::Utils)
21
- # for Rails 3.2
22
- ::Journey::Router::Utils
23
- else
24
- FallbackPathNormalizer
25
- end
26
-
17
+ # For Rails apps
18
+ ::ActionDispatch::Journey::Router::Utils
19
+ else
20
+ FallbackPathNormalizer
21
+ end
27
22
  end
@@ -9,7 +9,7 @@
9
9
  # end
10
10
  # end
11
11
  #
12
- # Rack::Attack.whitelist("localhost") {|req| req.localhost? }
12
+ # Rack::Attack.safelist("localhost") {|req| req.localhost? }
13
13
  #
14
14
  module Rack
15
15
  class Attack