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.
- checksums.yaml +4 -4
- data/README.md +19 -5
- data/lib/rack/attack.rb +97 -146
- data/lib/rack/attack/cache.rb +15 -1
- data/lib/rack/attack/check.rb +2 -1
- data/lib/rack/attack/configuration.rb +107 -0
- data/lib/rack/attack/path_normalizer.rb +20 -18
- data/lib/rack/attack/railtie.rb +13 -0
- data/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb +3 -1
- data/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +3 -1
- data/lib/rack/attack/store_proxy/redis_proxy.rb +16 -7
- data/lib/rack/attack/throttle.rb +32 -14
- data/lib/rack/attack/track.rb +6 -5
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/rails_middleware_spec.rb +35 -0
- data/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb +1 -3
- data/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +7 -1
- data/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +6 -1
- data/spec/acceptance/stores/connection_pool_dalli_client_spec.rb +3 -3
- data/spec/acceptance/throttling_spec.rb +19 -1
- data/spec/allow2ban_spec.rb +17 -14
- data/spec/fail2ban_spec.rb +17 -16
- data/spec/integration/offline_spec.rb +46 -1
- data/spec/rack_attack_instrumentation_spec.rb +1 -1
- data/spec/rack_attack_path_normalizer_spec.rb +2 -2
- data/spec/rack_attack_spec.rb +58 -13
- data/spec/rack_attack_throttle_spec.rb +43 -18
- data/spec/rack_attack_track_spec.rb +8 -5
- data/spec/spec_helper.rb +7 -9
- metadata +31 -21
@@ -1,24 +1,26 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
13
|
+
module FallbackPathNormalizer
|
14
|
+
def self.normalize_path(path)
|
15
|
+
path
|
16
|
+
end
|
15
17
|
end
|
16
|
-
end
|
17
18
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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) &&
|
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) &&
|
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
|
-
|
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::
|
63
|
+
rescue Redis::BaseConnectionError
|
55
64
|
nil
|
56
65
|
end
|
57
66
|
end
|
data/lib/rack/attack/throttle.rb
CHANGED
@@ -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
|
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
|
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
|
28
|
-
current_limit
|
29
|
-
|
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:
|
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
|
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
|
data/lib/rack/attack/track.rb
CHANGED
@@ -8,11 +8,12 @@ module Rack
|
|
8
8
|
def initialize(name, options = {}, &block)
|
9
9
|
options[:type] = :track
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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)
|
data/lib/rack/attack/version.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
data/spec/allow2ban_spec.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|