rack-attack 5.0.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 (63) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +190 -94
  3. data/Rakefile +11 -4
  4. data/bin/setup +8 -0
  5. data/lib/rack/attack.rb +83 -51
  6. data/lib/rack/attack/allow2ban.rb +2 -1
  7. data/lib/rack/attack/blocklist.rb +0 -1
  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 +2 -1
  11. data/lib/rack/attack/path_normalizer.rb +6 -11
  12. data/lib/rack/attack/safelist.rb +0 -1
  13. data/lib/rack/attack/store_proxy.rb +3 -12
  14. data/lib/rack/attack/store_proxy/dalli_proxy.rb +2 -3
  15. data/lib/rack/attack/store_proxy/mem_cache_proxy.rb +4 -5
  16. data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +19 -0
  17. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +35 -0
  18. data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
  19. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +5 -24
  20. data/lib/rack/attack/throttle.rb +16 -12
  21. data/lib/rack/attack/track.rb +3 -3
  22. data/lib/rack/attack/version.rb +1 -1
  23. data/spec/acceptance/allow2ban_spec.rb +71 -0
  24. data/spec/acceptance/blocking_ip_spec.rb +38 -0
  25. data/spec/acceptance/blocking_spec.rb +41 -0
  26. data/spec/acceptance/blocking_subnet_spec.rb +44 -0
  27. data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +126 -0
  28. data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +121 -0
  29. data/spec/acceptance/cache_store_config_for_throttle_spec.rb +48 -0
  30. data/spec/acceptance/cache_store_config_with_rails_spec.rb +31 -0
  31. data/spec/acceptance/customizing_blocked_response_spec.rb +41 -0
  32. data/spec/acceptance/customizing_throttled_response_spec.rb +59 -0
  33. data/spec/acceptance/extending_request_object_spec.rb +34 -0
  34. data/spec/acceptance/fail2ban_spec.rb +76 -0
  35. data/spec/acceptance/safelisting_ip_spec.rb +48 -0
  36. data/spec/acceptance/safelisting_spec.rb +53 -0
  37. data/spec/acceptance/safelisting_subnet_spec.rb +48 -0
  38. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +19 -0
  39. data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +22 -0
  40. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +18 -0
  41. data/spec/acceptance/stores/active_support_memory_store_spec.rb +16 -0
  42. data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +18 -0
  43. data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +18 -0
  44. data/spec/acceptance/stores/active_support_redis_store_spec.rb +18 -0
  45. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +22 -0
  46. data/spec/acceptance/stores/dalli_client_spec.rb +19 -0
  47. data/spec/acceptance/stores/redis_spec.rb +20 -0
  48. data/spec/acceptance/stores/redis_store_spec.rb +18 -0
  49. data/spec/acceptance/throttling_spec.rb +159 -0
  50. data/spec/acceptance/track_spec.rb +27 -0
  51. data/spec/acceptance/track_throttle_spec.rb +53 -0
  52. data/spec/allow2ban_spec.rb +9 -8
  53. data/spec/fail2ban_spec.rb +11 -9
  54. data/spec/integration/offline_spec.rb +21 -23
  55. data/spec/rack_attack_dalli_proxy_spec.rb +0 -2
  56. data/spec/rack_attack_request_spec.rb +1 -1
  57. data/spec/rack_attack_spec.rb +13 -14
  58. data/spec/rack_attack_throttle_spec.rb +28 -18
  59. data/spec/rack_attack_track_spec.rb +11 -8
  60. data/spec/spec_helper.rb +35 -14
  61. data/spec/support/cache_store_helper.rb +82 -0
  62. metadata +150 -65
  63. data/spec/integration/rack_attack_cache_spec.rb +0 -122
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|
@@ -10,11 +13,15 @@ namespace :test do
10
13
 
11
14
  Rake::TestTask.new(:integration) do |t|
12
15
  t.pattern = "spec/integration/*_spec.rb"
13
- t.warning = false
16
+ end
17
+
18
+ Rake::TestTask.new(:acceptance) do |t|
19
+ t.pattern = "spec/acceptance/**/*_spec.rb"
14
20
  end
15
21
  end
16
22
 
17
- desc 'Run tests'
18
- task :test => %w[test:units test:integration]
23
+ Rake::TestTask.new(:test) do |t|
24
+ t.pattern = "spec/**/*_spec.rb"
25
+ end
19
26
 
20
- 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,24 +1,30 @@
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 :Safelist, 'rack/attack/safelist'
10
- autoload :Blocklist, 'rack/attack/blocklist'
11
- autoload :Track, 'rack/attack/track'
12
- autoload :StoreProxy, 'rack/attack/store_proxy'
13
- autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
14
- autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
15
- autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
16
- autoload :Fail2Ban, 'rack/attack/fail2ban'
17
- autoload :Allow2Ban, 'rack/attack/allow2ban'
18
- 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'
19
26
 
20
27
  class << self
21
-
22
28
  attr_accessor :notifier, :blocklisted_response, :throttled_response
23
29
 
24
30
  def safelist(name, &block)
@@ -34,6 +40,18 @@ class Rack::Attack
34
40
  self.blocklists[name] = Blocklist.new(name, block)
35
41
  end
36
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)
53
+ end
54
+
37
55
  def blacklist(name, &block)
38
56
  warn "[DEPRECATION] 'Rack::Attack.blacklist' is deprecated. Please use 'blocklist' instead."
39
57
  blocklist(name, &block)
@@ -47,9 +65,12 @@ class Rack::Attack
47
65
  self.tracks[name] = Track.new(name, options, block)
48
66
  end
49
67
 
50
- def safelists; @safelists ||= {}; end
68
+ def safelists; @safelists ||= {}; end
69
+
51
70
  def blocklists; @blocklists ||= {}; end
71
+
52
72
  def throttles; @throttles ||= {}; end
73
+
53
74
  def tracks; @tracks ||= {}; end
54
75
 
55
76
  def whitelists
@@ -62,70 +83,84 @@ class Rack::Attack
62
83
  blocklists
63
84
  end
64
85
 
65
- def safelisted?(req)
66
- safelists.any? do |name, safelist|
67
- safelist[req]
68
- end
86
+ def safelisted?(request)
87
+ ip_safelists.any? { |safelist| safelist.matched_by?(request) } ||
88
+ safelists.any? { |_name, safelist| safelist.matched_by?(request) }
69
89
  end
70
90
 
71
- def whitelisted?(req)
91
+ def whitelisted?(request)
72
92
  warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead."
73
- safelisted?(req)
93
+ safelisted?(request)
74
94
  end
75
95
 
76
- def blocklisted?(req)
77
- blocklists.any? do |name, blocklist|
78
- blocklist[req]
79
- end
96
+ def blocklisted?(request)
97
+ ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
98
+ blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
80
99
  end
81
100
 
82
- def blacklisted?(req)
101
+ def blacklisted?(request)
83
102
  warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead."
84
- blocklisted?(req)
103
+ blocklisted?(request)
85
104
  end
86
105
 
87
- def throttled?(req)
88
- throttles.any? do |name, throttle|
89
- throttle[req]
106
+ def throttled?(request)
107
+ throttles.any? do |_name, throttle|
108
+ throttle.matched_by?(request)
90
109
  end
91
110
  end
92
111
 
93
- def tracked?(req)
94
- tracks.each_value do |tracker|
95
- tracker[req]
112
+ def tracked?(request)
113
+ tracks.each_value do |track|
114
+ track.matched_by?(request)
96
115
  end
97
116
  end
98
117
 
99
- def instrument(req)
100
- notifier.instrument('rack.attack', req) if notifier
118
+ def instrument(request)
119
+ notifier.instrument('rack.attack', request) if notifier
101
120
  end
102
121
 
103
122
  def cache
104
123
  @cache ||= Cache.new
105
124
  end
106
125
 
107
- def clear!
126
+ def clear_configuration
108
127
  @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {}
128
+ @ip_blocklists = []
129
+ @ip_safelists = []
130
+ end
131
+
132
+ def clear!
133
+ warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
134
+ clear_configuration
109
135
  end
110
136
 
111
137
  def blacklisted_response=(res)
112
138
  warn "[DEPRECATION] 'Rack::Attack.blacklisted_response=' is deprecated. Please use 'blocklisted_response=' instead."
113
- self.blocklisted_response=(res)
139
+ self.blocklisted_response = res
114
140
  end
115
141
 
116
142
  def blacklisted_response
117
143
  warn "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead."
118
- self.blocklisted_response
144
+ blocklisted_response
119
145
  end
120
146
 
147
+ private
148
+
149
+ def ip_blocklists
150
+ @ip_blocklists ||= []
151
+ end
152
+
153
+ def ip_safelists
154
+ @ip_safelists ||= []
155
+ end
121
156
  end
122
157
 
123
158
  # Set defaults
124
159
  @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
125
- @blocklisted_response = lambda {|env| [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] }
126
- @throttled_response = lambda {|env|
160
+ @blocklisted_response = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] }
161
+ @throttled_response = lambda { |env|
127
162
  retry_after = (env['rack.attack.match_data'] || {})[:period]
128
- [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"]]
129
164
  }
130
165
 
131
166
  def initialize(app)
@@ -134,23 +169,20 @@ class Rack::Attack
134
169
 
135
170
  def call(env)
136
171
  env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
137
- req = Rack::Attack::Request.new(env)
172
+ request = Rack::Attack::Request.new(env)
138
173
 
139
- if safelisted?(req)
174
+ if safelisted?(request)
140
175
  @app.call(env)
141
- elsif blocklisted?(req)
176
+ elsif blocklisted?(request)
142
177
  self.class.blocklisted_response.call(env)
143
- elsif throttled?(req)
178
+ elsif throttled?(request)
144
179
  self.class.throttled_response.call(env)
145
180
  else
146
- tracked?(req)
181
+ tracked?(request)
147
182
  @app.call(env)
148
183
  end
149
184
  end
150
185
 
151
186
  extend Forwardable
152
- def_delegators self, :safelisted?,
153
- :blocklisted?,
154
- :throttled?,
155
- :tracked?
187
+ def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked?
156
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)
@@ -5,7 +5,6 @@ module Rack
5
5
  super
6
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
-
@@ -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
@@ -5,7 +5,6 @@ module Rack
5
5
  super
6
6
  @type = :safelist
7
7
  end
8
-
9
8
  end
10
9
  end
11
10
  end
@@ -1,10 +1,7 @@
1
1
  module Rack
2
2
  class Attack
3
3
  module StoreProxy
4
- PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy]
5
-
6
- ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore']).freeze
7
- ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze
4
+ PROXIES = [DalliProxy, MemCacheStoreProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
8
5
 
9
6
  def self.build(store)
10
7
  client = unwrap_active_support_stores(store)
@@ -12,17 +9,11 @@ module Rack
12
9
  klass ? klass.new(client) : client
13
10
  end
14
11
 
15
-
16
- private
17
12
  def self.unwrap_active_support_stores(store)
18
13
  # ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
19
14
  # so use the raw Redis::Store instead.
20
- # We also want to use the underlying Dalli client instead of ::ActiveSupport::Cache::MemCacheStore,
21
- # and the MemCache client if using Rails 3.x
22
-
23
- client = store.instance_variable_get(:@data)
24
- if ACTIVE_SUPPORT_WRAPPER_CLASSES.include?(store.class.to_s) && ACTIVE_SUPPORT_CLIENTS.include?(client.class.to_s)
25
- client
15
+ if store.class.name == 'ActiveSupport::Cache::RedisStore'
16
+ store.instance_variable_get(:@data)
26
17
  else
27
18
  store
28
19
  end