rack-attack 6.0.0 → 6.3.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.
@@ -1,24 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Rack::Attack
4
- # When using Rack::Attack with a Rails app, developers expect the request path
5
- # to be normalized. In particular, trailing slashes are stripped.
6
- # (See https://git.io/v0rrR for implementation.)
7
- #
8
- # Look for an ActionDispatch utility class that Rails folks would expect
9
- # to normalize request paths. If unavailable, use a fallback class that
10
- # doesn't normalize the path (as a non-Rails rack app developer expects).
3
+ module Rack
4
+ class Attack
5
+ # When using Rack::Attack with a Rails app, developers expect the request path
6
+ # to be normalized. In particular, trailing slashes are stripped.
7
+ # (See https://git.io/v0rrR for implementation.)
8
+ #
9
+ # Look for an ActionDispatch utility class that Rails folks would expect
10
+ # to normalize request paths. If unavailable, use a fallback class that
11
+ # doesn't normalize the path (as a non-Rails rack app developer expects).
11
12
 
12
- module FallbackPathNormalizer
13
- def self.normalize_path(path)
14
- path
13
+ module FallbackPathNormalizer
14
+ def self.normalize_path(path)
15
+ path
16
+ end
15
17
  end
16
- end
17
18
 
18
- PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils)
19
- # For Rails apps
20
- ::ActionDispatch::Journey::Router::Utils
21
- else
22
- FallbackPathNormalizer
23
- end
19
+ PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils)
20
+ # For Rails apps
21
+ ::ActionDispatch::Journey::Router::Utils
22
+ else
23
+ FallbackPathNormalizer
24
+ end
25
+ end
24
26
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Attack
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "rack-attack.middleware" do |app|
7
+ if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("5.1")
8
+ app.middleware.use(Rack::Attack)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -7,7 +7,9 @@ module Rack
7
7
  module StoreProxy
8
8
  class ActiveSupportRedisStoreProxy < SimpleDelegator
9
9
  def self.handle?(store)
10
- defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisStore) && store.is_a?(::ActiveSupport::Cache::RedisStore)
10
+ defined?(::Redis) &&
11
+ defined?(::ActiveSupport::Cache::RedisStore) &&
12
+ store.is_a?(::ActiveSupport::Cache::RedisStore)
11
13
  end
12
14
 
13
15
  def increment(name, amount = 1, options = {})
@@ -7,7 +7,9 @@ module Rack
7
7
  module StoreProxy
8
8
  class MemCacheStoreProxy < SimpleDelegator
9
9
  def self.handle?(store)
10
- defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) && store.is_a?(::ActiveSupport::Cache::MemCacheStore)
10
+ defined?(::Dalli) &&
11
+ defined?(::ActiveSupport::Cache::MemCacheStore) &&
12
+ store.is_a?(::ActiveSupport::Cache::MemCacheStore)
11
13
  end
12
14
 
13
15
  def write(name, value, options = {})
@@ -31,27 +31,36 @@ module Rack
31
31
  end
32
32
 
33
33
  def increment(key, amount, options = {})
34
- count = nil
35
-
36
34
  rescuing do
37
35
  pipelined do
38
- count = incrby(key, amount)
36
+ incrby(key, amount)
39
37
  expire(key, options[:expires_in]) if options[:expires_in]
40
- end
38
+ end.first
41
39
  end
42
-
43
- count.value if count
44
40
  end
45
41
 
46
42
  def delete(key, _options = {})
47
43
  rescuing { del(key) }
48
44
  end
49
45
 
46
+ def delete_matched(matcher, _options = nil)
47
+ cursor = "0"
48
+
49
+ rescuing do
50
+ # Fetch keys in batches using SCAN to avoid blocking the Redis server.
51
+ loop do
52
+ cursor, keys = scan(cursor, match: matcher, count: 1000)
53
+ del(*keys) unless keys.empty?
54
+ break if cursor == "0"
55
+ end
56
+ end
57
+ end
58
+
50
59
  private
51
60
 
52
61
  def rescuing
53
62
  yield
54
- rescue Redis::BaseError
63
+ rescue Redis::BaseConnectionError
55
64
  nil
56
65
  end
57
66
  end
@@ -7,11 +7,12 @@ module Rack
7
7
 
8
8
  attr_reader :name, :limit, :period, :block, :type
9
9
  def initialize(name, options, &block)
10
- @name, @block = name, block
10
+ @name = name
11
+ @block = block
11
12
  MANDATORY_OPTIONS.each do |opt|
12
13
  raise ArgumentError, "Must pass #{opt.inspect} option" unless options[opt]
13
14
  end
14
- @limit = options[:limit]
15
+ @limit = options[:limit]
15
16
  @period = options[:period].respond_to?(:call) ? options[:period] : options[:period].to_i
16
17
  @type = options.fetch(:type, :throttle)
17
18
  end
@@ -22,33 +23,50 @@ module Rack
22
23
 
23
24
  def matched_by?(request)
24
25
  discriminator = block.call(request)
26
+
25
27
  return false unless discriminator
26
28
 
27
- current_period = period.respond_to?(:call) ? period.call(request) : period
28
- current_limit = limit.respond_to?(:call) ? limit.call(request) : limit
29
- key = "#{name}:#{discriminator}"
30
- count = cache.count(key, current_period)
31
- epoch_time = cache.last_epoch_time
29
+ current_period = period_for(request)
30
+ current_limit = limit_for(request)
31
+ count = cache.count("#{name}:#{discriminator}", current_period)
32
32
 
33
33
  data = {
34
+ discriminator: discriminator,
34
35
  count: count,
35
36
  period: current_period,
36
37
  limit: current_limit,
37
- epoch_time: epoch_time
38
+ epoch_time: cache.last_epoch_time
38
39
  }
39
40
 
40
- (request.env['rack.attack.throttle_data'] ||= {})[name] = data
41
-
42
41
  (count > current_limit).tap do |throttled|
42
+ annotate_request_with_throttle_data(request, data)
43
43
  if throttled
44
- request.env['rack.attack.matched'] = name
45
- request.env['rack.attack.match_discriminator'] = discriminator
46
- request.env['rack.attack.match_type'] = type
47
- request.env['rack.attack.match_data'] = data
44
+ annotate_request_with_matched_data(request, data)
48
45
  Rack::Attack.instrument(request)
49
46
  end
50
47
  end
51
48
  end
49
+
50
+ private
51
+
52
+ def period_for(request)
53
+ period.respond_to?(:call) ? period.call(request) : period
54
+ end
55
+
56
+ def limit_for(request)
57
+ limit.respond_to?(:call) ? limit.call(request) : limit
58
+ end
59
+
60
+ def annotate_request_with_throttle_data(request, data)
61
+ (request.env['rack.attack.throttle_data'] ||= {})[name] = data
62
+ end
63
+
64
+ def annotate_request_with_matched_data(request, data)
65
+ request.env['rack.attack.matched'] = name
66
+ request.env['rack.attack.match_discriminator'] = data[:discriminator]
67
+ request.env['rack.attack.match_type'] = type
68
+ request.env['rack.attack.match_data'] = data
69
+ end
52
70
  end
53
71
  end
54
72
  end
@@ -8,11 +8,12 @@ module Rack
8
8
  def initialize(name, options = {}, &block)
9
9
  options[:type] = :track
10
10
 
11
- if options[:limit] && options[:period]
12
- @filter = Throttle.new(name, options, &block)
13
- else
14
- @filter = Check.new(name, options, &block)
15
- end
11
+ @filter =
12
+ if options[:limit] && options[:period]
13
+ Throttle.new(name, options, &block)
14
+ else
15
+ Check.new(name, options, &block)
16
+ end
16
17
  end
17
18
 
18
19
  def matched_by?(request)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rack
4
4
  class Attack
5
- VERSION = '6.0.0'
5
+ VERSION = '6.3.0'
6
6
  end
7
7
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ if defined?(Rails)
6
+ describe "Middleware for Rails" do
7
+ before do
8
+ @app = Class.new(Rails::Application) do
9
+ config.eager_load = false
10
+ config.logger = Logger.new(nil) # avoid creating the log/ directory automatically
11
+ config.cache_store = :null_store # avoid creating tmp/ directory for cache
12
+ end
13
+ end
14
+
15
+ if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new("5.1")
16
+ it "is used by default" do
17
+ @app.initialize!
18
+ assert_equal 1, @app.middleware.count(Rack::Attack)
19
+ end
20
+
21
+ it "is not added when it was explicitly deleted" do
22
+ @app.config.middleware.delete(Rack::Attack)
23
+ @app.initialize!
24
+ refute @app.middleware.include?(Rack::Attack)
25
+ end
26
+ end
27
+
28
+ if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("5.1")
29
+ it "is not used by default" do
30
+ @app.initialize!
31
+ assert_equal 0, @app.middleware.count(Rack::Attack)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -15,8 +15,6 @@ if defined?(::ConnectionPool) && defined?(::Dalli)
15
15
  Rack::Attack.cache.store.clear
16
16
  end
17
17
 
18
- it_works_for_cache_backed_features(fetch_from_store: ->(key) {
19
- Rack::Attack.cache.store.read(key)
20
- })
18
+ it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) })
21
19
  end
22
20
  end
@@ -2,7 +2,13 @@
2
2
 
3
3
  require_relative "../../spec_helper"
4
4
 
5
- if defined?(::ConnectionPool) && defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore)
5
+ should_run =
6
+ defined?(::ConnectionPool) &&
7
+ defined?(::Redis) &&
8
+ Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") &&
9
+ defined?(::ActiveSupport::Cache::RedisCacheStore)
10
+
11
+ if should_run
6
12
  require_relative "../../support/cache_store_helper"
7
13
  require "timecop"
8
14
 
@@ -2,7 +2,12 @@
2
2
 
3
3
  require_relative "../../spec_helper"
4
4
 
5
- if defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore)
5
+ should_run =
6
+ defined?(::Redis) &&
7
+ Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") &&
8
+ defined?(::ActiveSupport::Cache::RedisCacheStore)
9
+
10
+ if should_run
6
11
  require_relative "../../support/cache_store_helper"
7
12
  require "timecop"
8
13
 
@@ -17,8 +17,8 @@ if defined?(::Dalli) && defined?(::ConnectionPool)
17
17
  Rack::Attack.cache.store.with { |client| client.flush_all }
18
18
  end
19
19
 
20
- it_works_for_cache_backed_features(fetch_from_store: ->(key) {
21
- Rack::Attack.cache.store.with { |client| client.fetch(key) }
22
- })
20
+ it_works_for_cache_backed_features(
21
+ fetch_from_store: ->(key) { Rack::Attack.cache.store.with { |client| client.fetch(key) } }
22
+ )
23
23
  end
24
24
  end
@@ -20,7 +20,7 @@ describe "#throttle" do
20
20
  get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
21
21
 
22
22
  assert_equal 429, last_response.status
23
- assert_equal "60", last_response.headers["Retry-After"]
23
+ assert_nil last_response.headers["Retry-After"]
24
24
  assert_equal "Retry later\n", last_response.body
25
25
 
26
26
  get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
@@ -34,6 +34,24 @@ describe "#throttle" do
34
34
  end
35
35
  end
36
36
 
37
+ it "returns correct Retry-After header if enabled" do
38
+ Rack::Attack.throttled_response_retry_after_header = true
39
+
40
+ Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request|
41
+ request.ip
42
+ end
43
+
44
+ Timecop.freeze(Time.at(0)) do
45
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
46
+ assert_equal 200, last_response.status
47
+ end
48
+
49
+ Timecop.freeze(Time.at(25)) do
50
+ get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
51
+ assert_equal "35", last_response.headers["Retry-After"]
52
+ end
53
+ end
54
+
37
55
  it "supports limit to be dynamic" do
38
56
  # Could be used to have different rate limits for authorized
39
57
  # vs general requests
@@ -20,7 +20,8 @@ describe 'Rack::Attack.Allow2Ban' do
20
20
  describe 'making ok request' do
21
21
  it 'succeeds' do
22
22
  get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
23
- last_response.status.must_equal 200
23
+
24
+ _(last_response.status).must_equal 200
24
25
  end
25
26
  end
26
27
 
@@ -29,17 +30,18 @@ describe 'Rack::Attack.Allow2Ban' do
29
30
  before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' }
30
31
 
31
32
  it 'succeeds' do
32
- last_response.status.must_equal 200
33
+ _(last_response.status).must_equal 200
33
34
  end
34
35
 
35
36
  it 'increases fail count' do
36
37
  key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
37
- @cache.store.read(key).must_equal 1
38
+
39
+ _(@cache.store.read(key)).must_equal 1
38
40
  end
39
41
 
40
42
  it 'is not banned' do
41
43
  key = "rack::attack:allow2ban:1.2.3.4"
42
- @cache.store.read(key).must_be_nil
44
+ _(@cache.store.read(key)).must_be_nil
43
45
  end
44
46
  end
45
47
 
@@ -51,17 +53,17 @@ describe 'Rack::Attack.Allow2Ban' do
51
53
  end
52
54
 
53
55
  it 'succeeds' do
54
- last_response.status.must_equal 200
56
+ _(last_response.status).must_equal 200
55
57
  end
56
58
 
57
59
  it 'increases fail count' do
58
60
  key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
59
- @cache.store.read(key).must_equal 2
61
+ _(@cache.store.read(key)).must_equal 2
60
62
  end
61
63
 
62
64
  it 'is banned' do
63
65
  key = "rack::attack:allow2ban:ban:1.2.3.4"
64
- @cache.store.read(key).must_equal 1
66
+ _(@cache.store.read(key)).must_equal 1
65
67
  end
66
68
  end
67
69
  end
@@ -77,7 +79,8 @@ describe 'Rack::Attack.Allow2Ban' do
77
79
  describe 'making request for other discriminator' do
78
80
  it 'succeeds' do
79
81
  get '/', {}, 'REMOTE_ADDR' => '2.2.3.4'
80
- last_response.status.must_equal 200
82
+
83
+ _(last_response.status).must_equal 200
81
84
  end
82
85
  end
83
86
 
@@ -87,17 +90,17 @@ describe 'Rack::Attack.Allow2Ban' do
87
90
  end
88
91
 
89
92
  it 'fails' do
90
- last_response.status.must_equal 403
93
+ _(last_response.status).must_equal 403
91
94
  end
92
95
 
93
96
  it 'does not increase fail count' do
94
97
  key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
95
- @cache.store.read(key).must_equal 2
98
+ _(@cache.store.read(key)).must_equal 2
96
99
  end
97
100
 
98
101
  it 'is still banned' do
99
102
  key = "rack::attack:allow2ban:ban:1.2.3.4"
100
- @cache.store.read(key).must_equal 1
103
+ _(@cache.store.read(key)).must_equal 1
101
104
  end
102
105
  end
103
106
 
@@ -107,17 +110,17 @@ describe 'Rack::Attack.Allow2Ban' do
107
110
  end
108
111
 
109
112
  it 'fails' do
110
- last_response.status.must_equal 403
113
+ _(last_response.status).must_equal 403
111
114
  end
112
115
 
113
116
  it 'does not increase fail count' do
114
117
  key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
115
- @cache.store.read(key).must_equal 2
118
+ _(@cache.store.read(key)).must_equal 2
116
119
  end
117
120
 
118
121
  it 'is still banned' do
119
122
  key = "rack::attack:allow2ban:ban:1.2.3.4"
120
- @cache.store.read(key).must_equal 1
123
+ _(@cache.store.read(key)).must_equal 1
121
124
  end
122
125
  end
123
126
  end