rack-attack 6.0.0 → 6.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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