rack-attack 5.3.1 → 5.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/Rakefile +3 -2
  4. data/lib/rack/attack.rb +23 -22
  5. data/lib/rack/attack/cache.rb +4 -3
  6. data/lib/rack/attack/check.rb +6 -8
  7. data/lib/rack/attack/store_proxy.rb +1 -1
  8. data/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +2 -2
  9. data/lib/rack/attack/store_proxy/redis_proxy.rb +54 -0
  10. data/lib/rack/attack/store_proxy/redis_store_proxy.rb +1 -22
  11. data/lib/rack/attack/throttle.rb +14 -11
  12. data/lib/rack/attack/track.rb +3 -3
  13. data/lib/rack/attack/version.rb +1 -1
  14. data/spec/acceptance/stores/active_support_dalli_store_spec.rb +41 -0
  15. data/spec/acceptance/stores/active_support_mem_cache_store_spec.rb +40 -0
  16. data/spec/acceptance/stores/{mem_cache_store_spec.rb → active_support_memory_store_spec.rb} +5 -5
  17. data/spec/acceptance/stores/{redis_cache_store_pooled_spec.rb → active_support_redis_cache_store_pooled_spec.rb} +4 -4
  18. data/spec/acceptance/stores/{redis_cache_store_spec.rb → active_support_redis_cache_store_spec.rb} +4 -4
  19. data/spec/acceptance/stores/active_support_redis_store_spec.rb +40 -0
  20. data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +42 -0
  21. data/spec/acceptance/stores/dalli_client_spec.rb +41 -0
  22. data/spec/acceptance/stores/redis_spec.rb +42 -0
  23. data/spec/acceptance/stores/redis_store_spec.rb +40 -0
  24. data/spec/integration/offline_spec.rb +21 -19
  25. data/spec/rack_attack_throttle_spec.rb +4 -4
  26. data/spec/rack_attack_track_spec.rb +4 -4
  27. data/spec/spec_helper.rb +15 -9
  28. metadata +84 -146
  29. data/spec/integration/rack_attack_cache_spec.rb +0 -124
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f06d0fae1f4dc5d960274ca06a4da7be66a1180a3df051595c98813296e80e46
4
- data.tar.gz: 32fa128bb61140836c8fcce73d1cd972c3987405e581a43dea2334d6ba05cfa7
3
+ metadata.gz: 123608043cbaa1604ab2d7b06c056010decd274dd4a5b1fe8f2175cec766fa4d
4
+ data.tar.gz: 2bd3ca91293545b76221608f144c1a43a458fbd9e6f2e8ea2647bf561d8ea9a9
5
5
  SHA512:
6
- metadata.gz: 7d7be10e1ddb908f07fb10aec414f47fcb9d23289eacd98a1a91e1f87ffa0212d63f424fff44bcfa0b0154263518d807e535bcd8791200c8dc290ea31bd4395e
7
- data.tar.gz: c99bfe7df1e11591e80526fd17b414494ddaa797906a4cced2beca884fe51db9fe9ce9d4dd99ee92ac98921a35f87361944ca4d8895b83a86ccc442b6d5cae31
6
+ metadata.gz: '06388ad68edc65019740c9ee77347f817b0769e782d0f8564ac5ac6b9c7fa819fe2157a10d89b570c78c14ef5a86fe080fac30aba8ebdfed281f2073785f5685'
7
+ data.tar.gz: a15cc2cef9eebda52d662fe3eeee3f187d5b575c089a1fa2cf5e0179012499f6440889349a47108c4017f8b73dd18985655dc933baf8c031e08d73ea23d1dbba
data/README.md CHANGED
@@ -303,13 +303,13 @@ Here's an example response that includes conventional `X-RateLimit-*` headers:
303
303
 
304
304
  ```ruby
305
305
  Rack::Attack.throttled_response = lambda do |env|
306
- now = Time.now
307
306
  match_data = env['rack.attack.match_data']
307
+ now = match_data[:epoch_time]
308
308
 
309
309
  headers = {
310
310
  'X-RateLimit-Limit' => match_data[:limit].to_s,
311
311
  'X-RateLimit-Remaining' => '0',
312
- 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
312
+ 'X-RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
313
313
  }
314
314
 
315
315
  [ 429, headers, ["Throttled\n"]]
@@ -320,7 +320,7 @@ end
320
320
  For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
321
321
 
322
322
  ```ruby
323
- request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l }
323
+ request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t }
324
324
  ```
325
325
 
326
326
  ## Logging & Instrumentation
data/Rakefile CHANGED
@@ -20,7 +20,8 @@ namespace :test do
20
20
  end
21
21
  end
22
22
 
23
- desc 'Run tests'
24
- task :test => %w[test:units test:integration test:acceptance]
23
+ Rake::TestTask.new(:test) do |t|
24
+ t.pattern = "spec/**/*_spec.rb"
25
+ end
25
26
 
26
27
  task :default => [:rubocop, :test]
@@ -17,6 +17,7 @@ class Rack::Attack
17
17
  autoload :StoreProxy, 'rack/attack/store_proxy'
18
18
  autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
19
19
  autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
20
+ autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
20
21
  autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
21
22
  autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
22
23
  autoload :Fail2Ban, 'rack/attack/fail2ban'
@@ -81,40 +82,40 @@ class Rack::Attack
81
82
  blocklists
82
83
  end
83
84
 
84
- def safelisted?(req)
85
- ip_safelists.any? { |safelist| safelist.match?(req) } ||
86
- safelists.any? { |_name, safelist| safelist.match?(req) }
85
+ def safelisted?(request)
86
+ ip_safelists.any? { |safelist| safelist.matched_by?(request) } ||
87
+ safelists.any? { |_name, safelist| safelist.matched_by?(request) }
87
88
  end
88
89
 
89
- def whitelisted?(req)
90
+ def whitelisted?(request)
90
91
  warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead."
91
- safelisted?(req)
92
+ safelisted?(request)
92
93
  end
93
94
 
94
- def blocklisted?(req)
95
- ip_blocklists.any? { |blocklist| blocklist.match?(req) } ||
96
- blocklists.any? { |_name, blocklist| blocklist.match?(req) }
95
+ def blocklisted?(request)
96
+ ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
97
+ blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
97
98
  end
98
99
 
99
- def blacklisted?(req)
100
+ def blacklisted?(request)
100
101
  warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead."
101
- blocklisted?(req)
102
+ blocklisted?(request)
102
103
  end
103
104
 
104
- def throttled?(req)
105
+ def throttled?(request)
105
106
  throttles.any? do |_name, throttle|
106
- throttle[req]
107
+ throttle.matched_by?(request)
107
108
  end
108
109
  end
109
110
 
110
- def tracked?(req)
111
- tracks.each_value do |tracker|
112
- tracker[req]
111
+ def tracked?(request)
112
+ tracks.each_value do |track|
113
+ track.matched_by?(request)
113
114
  end
114
115
  end
115
116
 
116
- def instrument(req)
117
- notifier.instrument('rack.attack', req) if notifier
117
+ def instrument(request)
118
+ notifier.instrument('rack.attack', request) if notifier
118
119
  end
119
120
 
120
121
  def cache
@@ -167,16 +168,16 @@ class Rack::Attack
167
168
 
168
169
  def call(env)
169
170
  env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
170
- req = Rack::Attack::Request.new(env)
171
+ request = Rack::Attack::Request.new(env)
171
172
 
172
- if safelisted?(req)
173
+ if safelisted?(request)
173
174
  @app.call(env)
174
- elsif blocklisted?(req)
175
+ elsif blocklisted?(request)
175
176
  self.class.blocklisted_response.call(env)
176
- elsif throttled?(req)
177
+ elsif throttled?(request)
177
178
  self.class.throttled_response.call(env)
178
179
  else
179
- tracked?(req)
180
+ tracked?(request)
180
181
  @app.call(env)
181
182
  end
182
183
  end
@@ -2,6 +2,7 @@ module Rack
2
2
  class Attack
3
3
  class Cache
4
4
  attr_accessor :prefix
5
+ attr_reader :last_epoch_time
5
6
 
6
7
  def initialize
7
8
  self.store = ::Rails.cache if defined?(::Rails.cache)
@@ -41,10 +42,10 @@ module Rack
41
42
  private
42
43
 
43
44
  def key_and_expiry(unprefixed_key, period)
44
- epoch_time = Time.now.to_i
45
+ @last_epoch_time = Time.now.to_i
45
46
  # Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA
46
- expires_in = (period - (epoch_time % period) + 1).to_i
47
- ["#{prefix}:#{(epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
47
+ expires_in = (period - (@last_epoch_time % period) + 1).to_i
48
+ ["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
48
49
  end
49
50
 
50
51
  def do_count(key, expires_in)
@@ -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
- alias_method :match?, :[]
21
19
  end
22
20
  end
23
21
  end
@@ -1,7 +1,7 @@
1
1
  module Rack
2
2
  class Attack
3
3
  module StoreProxy
4
- PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisCacheStoreProxy].freeze
4
+ PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy].freeze
5
5
 
6
6
  ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore', 'ActiveSupport::Cache::RedisCacheStore']).freeze
7
7
  ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze
@@ -5,7 +5,7 @@ module Rack
5
5
  module StoreProxy
6
6
  class RedisCacheStoreProxy < SimpleDelegator
7
7
  def self.handle?(store)
8
- defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
8
+ defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore)
9
9
  end
10
10
 
11
11
  def increment(name, amount = 1, options = {})
@@ -16,7 +16,7 @@ module Rack
16
16
  if options[:expires_in] && !read(name)
17
17
  write(name, amount, options)
18
18
 
19
- 1
19
+ amount
20
20
  else
21
21
  super
22
22
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module Rack
6
+ class Attack
7
+ module StoreProxy
8
+ class RedisProxy < SimpleDelegator
9
+ def initialize(*args)
10
+ if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3")
11
+ warn 'RackAttack requires Redis gem >= 3.0.0.'
12
+ end
13
+
14
+ super(*args)
15
+ end
16
+
17
+ def self.handle?(store)
18
+ defined?(::Redis) && store.is_a?(::Redis)
19
+ end
20
+
21
+ def read(key)
22
+ get(key)
23
+ rescue Redis::BaseError
24
+ end
25
+
26
+ def write(key, value, options = {})
27
+ if (expires_in = options[:expires_in])
28
+ setex(key, expires_in, value)
29
+ else
30
+ set(key, value)
31
+ end
32
+ rescue Redis::BaseError
33
+ end
34
+
35
+ def increment(key, amount, options = {})
36
+ count = nil
37
+
38
+ pipelined do
39
+ count = incrby(key, amount)
40
+ expire(key, options[:expires_in]) if options[:expires_in]
41
+ end
42
+
43
+ count.value if count
44
+ rescue Redis::BaseError
45
+ end
46
+
47
+ def delete(key, _options = {})
48
+ del(key)
49
+ rescue Redis::BaseError
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -3,15 +3,11 @@ require 'delegate'
3
3
  module Rack
4
4
  class Attack
5
5
  module StoreProxy
6
- class RedisStoreProxy < SimpleDelegator
6
+ class RedisStoreProxy < RedisProxy
7
7
  def self.handle?(store)
8
8
  defined?(::Redis::Store) && store.is_a?(::Redis::Store)
9
9
  end
10
10
 
11
- def initialize(store)
12
- super(store)
13
- end
14
-
15
11
  def read(key)
16
12
  get(key, raw: true)
17
13
  rescue Redis::BaseError
@@ -25,23 +21,6 @@ module Rack
25
21
  end
26
22
  rescue Redis::BaseError
27
23
  end
28
-
29
- def increment(key, amount, options = {})
30
- count = nil
31
-
32
- pipelined do
33
- count = incrby(key, amount)
34
- expire(key, options[:expires_in]) if options[:expires_in]
35
- end
36
-
37
- count.value if count
38
- rescue Redis::BaseError
39
- end
40
-
41
- def delete(key, _options = {})
42
- del(key)
43
- rescue Redis::BaseError
44
- end
45
24
  end
46
25
  end
47
26
  end
@@ -18,29 +18,32 @@ module Rack
18
18
  Rack::Attack.cache
19
19
  end
20
20
 
21
- def [](req)
22
- discriminator = block[req]
21
+ def matched_by?(request)
22
+ discriminator = block.call(request)
23
23
  return false unless discriminator
24
24
 
25
- current_period = period.respond_to?(:call) ? period.call(req) : period
26
- current_limit = limit.respond_to?(:call) ? limit.call(req) : limit
25
+ current_period = period.respond_to?(:call) ? period.call(request) : period
26
+ current_limit = limit.respond_to?(:call) ? limit.call(request) : limit
27
27
  key = "#{name}:#{discriminator}"
28
28
  count = cache.count(key, current_period)
29
+ epoch_time = cache.last_epoch_time
29
30
 
30
31
  data = {
31
32
  :count => count,
32
33
  :period => current_period,
33
- :limit => current_limit
34
+ :limit => current_limit,
35
+ :epoch_time => epoch_time
34
36
  }
35
- (req.env['rack.attack.throttle_data'] ||= {})[name] = data
37
+
38
+ (request.env['rack.attack.throttle_data'] ||= {})[name] = data
36
39
 
37
40
  (count > current_limit).tap do |throttled|
38
41
  if throttled
39
- req.env['rack.attack.matched'] = name
40
- req.env['rack.attack.match_discriminator'] = discriminator
41
- req.env['rack.attack.match_type'] = type
42
- req.env['rack.attack.match_data'] = data
43
- Rack::Attack.instrument(req)
42
+ request.env['rack.attack.matched'] = name
43
+ request.env['rack.attack.match_discriminator'] = discriminator
44
+ request.env['rack.attack.match_type'] = type
45
+ request.env['rack.attack.match_data'] = data
46
+ Rack::Attack.instrument(request)
44
47
  end
45
48
  end
46
49
  end
@@ -1,8 +1,6 @@
1
1
  module Rack
2
2
  class Attack
3
3
  class Track
4
- extend Forwardable
5
-
6
4
  attr_reader :filter
7
5
 
8
6
  def initialize(name, options = {}, block)
@@ -15,7 +13,9 @@ module Rack
15
13
  end
16
14
  end
17
15
 
18
- def_delegator :@filter, :[]
16
+ def matched_by?(request)
17
+ filter.matched_by?(request)
18
+ end
19
19
  end
20
20
  end
21
21
  end
@@ -1,5 +1,5 @@
1
1
  module Rack
2
2
  class Attack
3
- VERSION = '5.3.1'
3
+ VERSION = '5.4.0'
4
4
  end
5
5
  end
@@ -0,0 +1,41 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::Dalli)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "active_support/cache/dalli_store"
6
+ require "timecop"
7
+
8
+ describe "ActiveSupport::Cache::DalliStore as a cache backend" do
9
+ before do
10
+ Rack::Attack.cache.store = ActiveSupport::Cache::DalliStore.new
11
+ end
12
+
13
+ after do
14
+ Rack::Attack.cache.store.clear
15
+ end
16
+
17
+ it_works_for_cache_backed_features
18
+
19
+ it "doesn't leak keys" do
20
+ Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request|
21
+ request.ip
22
+ end
23
+
24
+ key = nil
25
+
26
+ # Freeze time during these statement to be sure that the key used by rack attack is the same
27
+ # we pre-calculate in local variable `key`
28
+ Timecop.freeze do
29
+ key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4"
30
+
31
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
32
+ end
33
+
34
+ assert Rack::Attack.cache.store.fetch(key)
35
+
36
+ sleep 2.1
37
+
38
+ assert_nil Rack::Attack.cache.store.fetch(key)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "../../spec_helper"
2
+
3
+ if defined?(::Dalli)
4
+ require_relative "../../support/cache_store_helper"
5
+ require "timecop"
6
+
7
+ describe "ActiveSupport::Cache::MemCacheStore as a cache backend" do
8
+ before do
9
+ Rack::Attack.cache.store = ActiveSupport::Cache::MemCacheStore.new
10
+ end
11
+
12
+ after do
13
+ Rack::Attack.cache.store.flush_all
14
+ end
15
+
16
+ it_works_for_cache_backed_features
17
+
18
+ it "doesn't leak keys" do
19
+ Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request|
20
+ request.ip
21
+ end
22
+
23
+ key = nil
24
+
25
+ # Freeze time during these statement to be sure that the key used by rack attack is the same
26
+ # we pre-calculate in local variable `key`
27
+ Timecop.freeze do
28
+ key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4"
29
+
30
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
31
+ end
32
+
33
+ assert Rack::Attack.cache.store.get(key)
34
+
35
+ sleep 2.1
36
+
37
+ assert_nil Rack::Attack.cache.store.get(key)
38
+ end
39
+ end
40
+ end