rack-attack 5.3.1 → 5.4.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 (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