wkimeria-rack-attack 4.1.2
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 +15 -0
- data/README.md +308 -0
- data/Rakefile +18 -0
- data/lib/rack/attack.rb +122 -0
- data/lib/rack/attack/allow2ban.rb +23 -0
- data/lib/rack/attack/blacklist.rb +12 -0
- data/lib/rack/attack/cache.rb +57 -0
- data/lib/rack/attack/check.rb +23 -0
- data/lib/rack/attack/conditional_throttle.rb +17 -0
- data/lib/rack/attack/fail2ban.rb +48 -0
- data/lib/rack/attack/request.rb +19 -0
- data/lib/rack/attack/store_proxy.rb +22 -0
- data/lib/rack/attack/store_proxy/dalli_proxy.rb +65 -0
- data/lib/rack/attack/store_proxy/redis_store_proxy.rb +49 -0
- data/lib/rack/attack/throttle.rb +50 -0
- data/lib/rack/attack/track.rb +21 -0
- data/lib/rack/attack/version.rb +5 -0
- data/lib/rack/attack/whitelist.rb +11 -0
- data/spec/allow2ban_spec.rb +121 -0
- data/spec/fail2ban_spec.rb +121 -0
- data/spec/integration/offline_spec.rb +47 -0
- data/spec/integration/rack_attack_cache_spec.rb +86 -0
- data/spec/rack_attack_conditional_throttle_spec.rb +53 -0
- data/spec/rack_attack_dalli_proxy_spec.rb +10 -0
- data/spec/rack_attack_request_spec.rb +19 -0
- data/spec/rack_attack_spec.rb +50 -0
- data/spec/rack_attack_throttle_spec.rb +64 -0
- data/spec/rack_attack_track_spec.rb +58 -0
- data/spec/spec_helper.rb +40 -0
- metadata +209 -0
@@ -0,0 +1,121 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
describe 'Rack::Attack.Fail2Ban' do
|
3
|
+
before do
|
4
|
+
# Use a long findtime; failures due to cache key rotation less likely
|
5
|
+
@cache = Rack::Attack.cache
|
6
|
+
@findtime = 60
|
7
|
+
@bantime = 60
|
8
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
9
|
+
@f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2}
|
10
|
+
Rack::Attack.blacklist('pentest') do |req|
|
11
|
+
Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe 'discriminator has not been banned' do
|
16
|
+
describe 'making ok request' do
|
17
|
+
it 'succeeds' do
|
18
|
+
get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
19
|
+
last_response.status.must_equal 200
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe 'making failing request' do
|
24
|
+
describe 'when not at maxretry' do
|
25
|
+
before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
26
|
+
it 'fails' do
|
27
|
+
last_response.status.must_equal 403
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'increases fail count' do
|
31
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
32
|
+
@cache.store.read(key).must_equal 1
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'is not banned' do
|
36
|
+
key = "rack::attack:fail2ban:1.2.3.4"
|
37
|
+
@cache.store.read(key).must_be_nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe 'when at maxretry' do
|
42
|
+
before do
|
43
|
+
# maxretry is 2 - so hit with an extra failed request first
|
44
|
+
get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
45
|
+
get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'fails' do
|
49
|
+
last_response.status.must_equal 403
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'increases fail count' do
|
53
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
54
|
+
@cache.store.read(key).must_equal 2
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'is banned' do
|
58
|
+
key = "rack::attack:fail2ban:ban:1.2.3.4"
|
59
|
+
@cache.store.read(key).must_equal 1
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe 'discriminator has been banned' do
|
67
|
+
before do
|
68
|
+
# maxretry is 2 - so hit enough times to get banned
|
69
|
+
get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
70
|
+
get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
71
|
+
end
|
72
|
+
|
73
|
+
describe 'making request for other discriminator' do
|
74
|
+
it 'succeeds' do
|
75
|
+
get '/', {}, 'REMOTE_ADDR' => '2.2.3.4'
|
76
|
+
last_response.status.must_equal 200
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe 'making ok request' do
|
81
|
+
before do
|
82
|
+
get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'fails' do
|
86
|
+
last_response.status.must_equal 403
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'does not increase fail count' do
|
90
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
91
|
+
@cache.store.read(key).must_equal 2
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'is still banned' do
|
95
|
+
key = "rack::attack:fail2ban:ban:1.2.3.4"
|
96
|
+
@cache.store.read(key).must_equal 1
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe 'making failing request' do
|
101
|
+
before do
|
102
|
+
get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'fails' do
|
106
|
+
last_response.status.must_equal 403
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'does not increase fail count' do
|
110
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
111
|
+
@cache.store.read(key).must_equal 2
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'is still banned' do
|
115
|
+
key = "rack::attack:fail2ban:ban:1.2.3.4"
|
116
|
+
@cache.store.read(key).must_equal 1
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'active_support/cache'
|
2
|
+
require 'active_support/cache/redis_store'
|
3
|
+
require 'dalli'
|
4
|
+
require_relative '../spec_helper'
|
5
|
+
|
6
|
+
OfflineExamples = Minitest::SharedExamples.new do
|
7
|
+
|
8
|
+
it 'should write' do
|
9
|
+
@cache.write('cache-test-key', 'foobar', 1)
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'should read' do
|
13
|
+
@cache.read('cache-test-key')
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should count' do
|
17
|
+
@cache.send(:do_count, 'rack::attack::cache-test-key', 1)
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
describe 'when Redis is offline' do
|
23
|
+
include OfflineExamples
|
24
|
+
|
25
|
+
before {
|
26
|
+
@cache = Rack::Attack::Cache.new
|
27
|
+
# Use presumably unused port for Redis client
|
28
|
+
@cache.store = ActiveSupport::Cache::RedisStore.new(:host => '127.0.0.1', :port => 3333)
|
29
|
+
}
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
describe 'when Memcached is offline' do
|
34
|
+
include OfflineExamples
|
35
|
+
|
36
|
+
before {
|
37
|
+
Dalli.logger.level = Logger::FATAL
|
38
|
+
|
39
|
+
@cache = Rack::Attack::Cache.new
|
40
|
+
@cache.store = Dalli::Client.new('127.0.0.1:22122')
|
41
|
+
}
|
42
|
+
|
43
|
+
after {
|
44
|
+
Dalli.logger.level = Logger::INFO
|
45
|
+
}
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Rack::Attack::Cache do
|
4
|
+
def delete(key)
|
5
|
+
if @cache.store.respond_to?(:delete)
|
6
|
+
@cache.store.delete(key)
|
7
|
+
else
|
8
|
+
@cache.store.del(key)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def sleep_until_expired
|
13
|
+
sleep(@expires_in * 1.1) # Add 10% to reduce errors
|
14
|
+
end
|
15
|
+
|
16
|
+
require 'active_support/cache/dalli_store'
|
17
|
+
require 'active_support/cache/redis_store'
|
18
|
+
require 'connection_pool'
|
19
|
+
cache_stores = [
|
20
|
+
ActiveSupport::Cache::MemoryStore.new,
|
21
|
+
ActiveSupport::Cache::DalliStore.new("127.0.0.1"),
|
22
|
+
ActiveSupport::Cache::RedisStore.new("127.0.0.1"),
|
23
|
+
Dalli::Client.new,
|
24
|
+
ConnectionPool.new { Dalli::Client.new },
|
25
|
+
Redis::Store.new
|
26
|
+
]
|
27
|
+
|
28
|
+
cache_stores.each do |store|
|
29
|
+
store = Rack::Attack::StoreProxy.build(store)
|
30
|
+
describe "with #{store.class}" do
|
31
|
+
|
32
|
+
before {
|
33
|
+
@cache = Rack::Attack::Cache.new
|
34
|
+
@key = "rack::attack:cache-test-key"
|
35
|
+
@expires_in = 1
|
36
|
+
@cache.store = store
|
37
|
+
delete(@key)
|
38
|
+
}
|
39
|
+
|
40
|
+
after { delete(@key) }
|
41
|
+
|
42
|
+
describe "do_count once" do
|
43
|
+
it "should be 1" do
|
44
|
+
@cache.send(:do_count, @key, @expires_in).must_equal 1
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe "do_count twice" do
|
49
|
+
it "must be 2" do
|
50
|
+
@cache.send(:do_count, @key, @expires_in)
|
51
|
+
@cache.send(:do_count, @key, @expires_in).must_equal 2
|
52
|
+
end
|
53
|
+
end
|
54
|
+
describe "do_count after expires_in" do
|
55
|
+
it "must be 1" do
|
56
|
+
@cache.send(:do_count, @key, @expires_in)
|
57
|
+
sleep_until_expired
|
58
|
+
@cache.send(:do_count, @key, @expires_in).must_equal 1
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "write" do
|
63
|
+
it "should write a value to the store with prefix" do
|
64
|
+
@cache.write("cache-test-key", "foobar", 1)
|
65
|
+
store.read(@key).must_equal "foobar"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
describe "write after expiry" do
|
70
|
+
it "must not have a value" do
|
71
|
+
@cache.write("cache-test-key", "foobar", @expires_in)
|
72
|
+
sleep_until_expired
|
73
|
+
store.read(@key).must_be :nil?
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "read" do
|
78
|
+
it "must read the value with a prefix" do
|
79
|
+
store.write(@key, "foobar", :expires_in => @expires_in)
|
80
|
+
@cache.read("cache-test-key").must_equal "foobar"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
describe 'Rack::Attack.conditional_throttle' do
|
3
|
+
before do
|
4
|
+
@period = 60 # Use a long period; failures due to cache key rotation less likely
|
5
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
6
|
+
Rack::Attack.conditional_throttle('login', :limit => 1, :period => @period) { |req| req.ip }
|
7
|
+
end
|
8
|
+
|
9
|
+
it('should have a throttle'){ Rack::Attack.throttles.key?('login') }
|
10
|
+
allow_ok_requests
|
11
|
+
|
12
|
+
describe 'a single successful request' do
|
13
|
+
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
14
|
+
it 'should not set the counter for one request' do
|
15
|
+
key = "rack::attack:#{Time.now.to_i/@period}:login:1.2.3.4"
|
16
|
+
Rack::Attack.cache.store.read(key).must_equal nil
|
17
|
+
end
|
18
|
+
end
|
19
|
+
describe "with 2 requests" do
|
20
|
+
before do
|
21
|
+
2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
22
|
+
end
|
23
|
+
it 'should not block the last request' do
|
24
|
+
last_response.status.must_equal 200
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe 'a successful request followed by failed request' do
|
29
|
+
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
30
|
+
it 'should increment counter for failed request' do
|
31
|
+
key = "rack::attack:#{Time.now.to_i/@period}:login:1.2.3.4"
|
32
|
+
Rack::Attack.increment_throttle_counter('login', '1.2.3.4')
|
33
|
+
Rack::Attack.cache.store.read(key).must_equal 1
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe 'a successful request followed by two failed request followed by successful request' do
|
38
|
+
before {
|
39
|
+
get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
40
|
+
@key = "rack::attack:#{Time.now.to_i/@period}:login:1.2.3.4"
|
41
|
+
Rack::Attack.increment_throttle_counter('login', '1.2.3.4')
|
42
|
+
Rack::Attack.increment_throttle_counter('login', '1.2.3.4')
|
43
|
+
}
|
44
|
+
it 'should increment counter for failed request' do
|
45
|
+
|
46
|
+
Rack::Attack.cache.store.read(@key).must_equal 2
|
47
|
+
get '/', {}, 'REMOTE_ADDR' => '1.2.3.4'
|
48
|
+
Rack::Attack.cache.store.read(@key).must_equal 2
|
49
|
+
last_response.status.must_equal 429
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
@@ -0,0 +1,10 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe Rack::Attack::StoreProxy::DalliProxy do
|
4
|
+
|
5
|
+
it 'should stub Dalli::Client#with on older clients' do
|
6
|
+
proxy = Rack::Attack::StoreProxy::DalliProxy.new(Class.new)
|
7
|
+
proxy.with {} # will not raise an error
|
8
|
+
end
|
9
|
+
|
10
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Rack::Attack' do
|
4
|
+
describe 'helpers' do
|
5
|
+
before do
|
6
|
+
class Rack::Attack::Request
|
7
|
+
def remote_ip
|
8
|
+
ip
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
Rack::Attack.whitelist('valid IP') do |req|
|
13
|
+
req.remote_ip == "127.0.0.1"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
allow_ok_requests
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Rack::Attack' do
|
4
|
+
allow_ok_requests
|
5
|
+
|
6
|
+
describe 'blacklist' do
|
7
|
+
before do
|
8
|
+
@bad_ip = '1.2.3.4'
|
9
|
+
Rack::Attack.blacklist("ip #{@bad_ip}") {|req| req.ip == @bad_ip }
|
10
|
+
end
|
11
|
+
|
12
|
+
it('has a blacklist') { Rack::Attack.blacklists.key?("ip #{@bad_ip}") }
|
13
|
+
|
14
|
+
describe "a bad request" do
|
15
|
+
before { get '/', {}, 'REMOTE_ADDR' => @bad_ip }
|
16
|
+
it "should return a blacklist response" do
|
17
|
+
get '/', {}, 'REMOTE_ADDR' => @bad_ip
|
18
|
+
last_response.status.must_equal 403
|
19
|
+
last_response.body.must_equal "Forbidden\n"
|
20
|
+
end
|
21
|
+
it "should tag the env" do
|
22
|
+
last_request.env['rack.attack.matched'].must_equal "ip #{@bad_ip}"
|
23
|
+
last_request.env['rack.attack.match_type'].must_equal :blacklist
|
24
|
+
end
|
25
|
+
|
26
|
+
allow_ok_requests
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "and whitelist" do
|
30
|
+
before do
|
31
|
+
@good_ua = 'GoodUA'
|
32
|
+
Rack::Attack.whitelist("good ua") {|req| req.user_agent == @good_ua }
|
33
|
+
end
|
34
|
+
|
35
|
+
it('has a whitelist'){ Rack::Attack.whitelists.key?("good ua") }
|
36
|
+
describe "with a request match both whitelist & blacklist" do
|
37
|
+
before { get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua }
|
38
|
+
it "should allow whitelists before blacklists" do
|
39
|
+
get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua
|
40
|
+
last_response.status.must_equal 200
|
41
|
+
end
|
42
|
+
it "should tag the env" do
|
43
|
+
last_request.env['rack.attack.matched'].must_equal 'good ua'
|
44
|
+
last_request.env['rack.attack.match_type'].must_equal :whitelist
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
describe 'Rack::Attack.throttle' do
|
3
|
+
before do
|
4
|
+
@period = 60 # Use a long period; failures due to cache key rotation less likely
|
5
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
6
|
+
Rack::Attack.throttle('ip/sec', :limit => 1, :period => @period) { |req| req.ip }
|
7
|
+
end
|
8
|
+
|
9
|
+
it('should have a throttle'){ Rack::Attack.throttles.key?('ip/sec') }
|
10
|
+
allow_ok_requests
|
11
|
+
|
12
|
+
describe 'a single request' do
|
13
|
+
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
14
|
+
it 'should set the counter for one request' do
|
15
|
+
key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4"
|
16
|
+
Rack::Attack.cache.store.read(key).must_equal 1
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'should populate throttle data' do
|
20
|
+
data = { :count => 1, :limit => 1, :period => @period }
|
21
|
+
last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data
|
22
|
+
end
|
23
|
+
end
|
24
|
+
describe "with 2 requests" do
|
25
|
+
before do
|
26
|
+
2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
27
|
+
end
|
28
|
+
it 'should block the last request' do
|
29
|
+
last_response.status.must_equal 429
|
30
|
+
end
|
31
|
+
it 'should tag the env' do
|
32
|
+
last_request.env['rack.attack.matched'].must_equal 'ip/sec'
|
33
|
+
last_request.env['rack.attack.match_type'].must_equal :throttle
|
34
|
+
last_request.env['rack.attack.match_data'].must_equal({:count => 2, :limit => 1, :period => @period})
|
35
|
+
last_request.env['rack.attack.match_discriminator'].must_equal('1.2.3.4')
|
36
|
+
end
|
37
|
+
it 'should set a Retry-After header' do
|
38
|
+
last_response.headers['Retry-After'].must_equal @period.to_s
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe 'Rack::Attack.throttle with limit as proc' do
|
44
|
+
before do
|
45
|
+
@period = 60 # Use a long period; failures due to cache key rotation less likely
|
46
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
47
|
+
Rack::Attack.throttle('ip/sec', :limit => lambda {|req| 1}, :period => @period) { |req| req.ip }
|
48
|
+
end
|
49
|
+
|
50
|
+
allow_ok_requests
|
51
|
+
|
52
|
+
describe 'a single request' do
|
53
|
+
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
54
|
+
it 'should set the counter for one request' do
|
55
|
+
key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4"
|
56
|
+
Rack::Attack.cache.store.read(key).must_equal 1
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'should populate throttle data' do
|
60
|
+
data = { :count => 1, :limit => 1, :period => @period }
|
61
|
+
last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|