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