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,23 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Allow2Ban < Fail2Ban
|
4
|
+
class << self
|
5
|
+
protected
|
6
|
+
def key_prefix
|
7
|
+
'allow2ban'
|
8
|
+
end
|
9
|
+
|
10
|
+
# everything the same here except we return only return true
|
11
|
+
# (blocking the request) if they have tripped the limit.
|
12
|
+
def fail!(discriminator, bantime, findtime, maxretry)
|
13
|
+
count = cache.count("#{key_prefix}:count:#{discriminator}", findtime)
|
14
|
+
if count >= maxretry
|
15
|
+
ban!(discriminator, bantime)
|
16
|
+
end
|
17
|
+
# we may not block them this time, but they're banned for next time
|
18
|
+
false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Cache
|
4
|
+
|
5
|
+
attr_accessor :prefix
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
self.store = ::Rails.cache if defined?(::Rails.cache)
|
9
|
+
@prefix = 'rack::attack'
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :store
|
13
|
+
def store=(store)
|
14
|
+
@store = StoreProxy.build(store)
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_count(unprefixed_key, period)
|
18
|
+
if store.class == Rack::Attack::StoreProxy::RedisStoreProxy
|
19
|
+
store.raw_read(build_key(unprefixed_key, period))
|
20
|
+
else
|
21
|
+
store.read(build_key(unprefixed_key, period))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def count(unprefixed_key, period)
|
26
|
+
epoch_time = Time.now.to_i
|
27
|
+
# Add 1 to expires_in to avoid timing error: http://git.io/i1PHXA
|
28
|
+
expires_in = period - (epoch_time % period) + 1
|
29
|
+
do_count(build_key(unprefixed_key, period), expires_in)
|
30
|
+
end
|
31
|
+
|
32
|
+
def read(unprefixed_key)
|
33
|
+
store.read("#{prefix}:#{unprefixed_key}")
|
34
|
+
end
|
35
|
+
|
36
|
+
def write(unprefixed_key, value, expires_in)
|
37
|
+
store.write("#{prefix}:#{unprefixed_key}", value, :expires_in => expires_in)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
def do_count(key, expires_in)
|
42
|
+
result = store.increment(key, 1, :expires_in => expires_in)
|
43
|
+
|
44
|
+
# NB: Some stores return nil when incrementing uninitialized values
|
45
|
+
if result.nil?
|
46
|
+
store.write(key, 1, :expires_in => expires_in)
|
47
|
+
end
|
48
|
+
result || 1
|
49
|
+
end
|
50
|
+
|
51
|
+
def build_key(unprefixed_key, period)
|
52
|
+
epoch_time = Time.now.to_i
|
53
|
+
"#{prefix}:#{(epoch_time/period).to_i}:#{unprefixed_key}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Check
|
4
|
+
attr_reader :name, :block, :type
|
5
|
+
def initialize(name, options = {}, block)
|
6
|
+
@name, @block = name, block
|
7
|
+
@type = options.fetch(:type, nil)
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](req)
|
11
|
+
block[req].tap {|match|
|
12
|
+
if match
|
13
|
+
req.env["rack.attack.matched"] = name
|
14
|
+
req.env["rack.attack.match_type"] = type
|
15
|
+
Rack::Attack.instrument(req)
|
16
|
+
end
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class ConditionalThrottle < ::Rack::Attack::Throttle
|
4
|
+
|
5
|
+
def increment_counter(discriminator)
|
6
|
+
key = "#{name}:#{discriminator}"
|
7
|
+
cache.count(key, period)
|
8
|
+
end
|
9
|
+
|
10
|
+
def get_count(discriminator)
|
11
|
+
key = "#{name}:#{discriminator}"
|
12
|
+
count = cache.get_count(key, period)
|
13
|
+
count ? count.to_i : 0
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Fail2Ban
|
4
|
+
class << self
|
5
|
+
def filter(discriminator, options)
|
6
|
+
bantime = options[:bantime] or raise ArgumentError, "Must pass bantime option"
|
7
|
+
findtime = options[:findtime] or raise ArgumentError, "Must pass findtime option"
|
8
|
+
maxretry = options[:maxretry] or raise ArgumentError, "Must pass maxretry option"
|
9
|
+
|
10
|
+
if banned?(discriminator)
|
11
|
+
# Return true for blacklist
|
12
|
+
true
|
13
|
+
elsif yield
|
14
|
+
fail!(discriminator, bantime, findtime, maxretry)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
def key_prefix
|
20
|
+
'fail2ban'
|
21
|
+
end
|
22
|
+
|
23
|
+
def fail!(discriminator, bantime, findtime, maxretry)
|
24
|
+
count = cache.count("#{key_prefix}:count:#{discriminator}", findtime)
|
25
|
+
if count >= maxretry
|
26
|
+
ban!(discriminator, bantime)
|
27
|
+
end
|
28
|
+
|
29
|
+
true
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
private
|
34
|
+
def ban!(discriminator, bantime)
|
35
|
+
cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime)
|
36
|
+
end
|
37
|
+
|
38
|
+
def banned?(discriminator)
|
39
|
+
cache.read("#{key_prefix}:ban:#{discriminator}")
|
40
|
+
end
|
41
|
+
|
42
|
+
def cache
|
43
|
+
Rack::Attack.cache
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# Rack::Attack::Request is the same as ::Rack::Request by default.
|
2
|
+
#
|
3
|
+
# This is a safe place to add custom helper methods to the request object
|
4
|
+
# through monkey patching:
|
5
|
+
#
|
6
|
+
# class Rack::Attack::Request < ::Rack::Request
|
7
|
+
# def localhost?
|
8
|
+
# ip == "127.0.0.1"
|
9
|
+
# end
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# Rack::Attack.whitelist("localhost") {|req| req.localhost? }
|
13
|
+
#
|
14
|
+
module Rack
|
15
|
+
class Attack
|
16
|
+
class Request < ::Rack::Request
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
module StoreProxy
|
4
|
+
PROXIES = [DalliProxy, RedisStoreProxy]
|
5
|
+
|
6
|
+
def self.build(store)
|
7
|
+
# RedisStore#increment needs different behavior, so detect that
|
8
|
+
# (method has an arity of 2; must call #expire separately
|
9
|
+
if defined?(::ActiveSupport::Cache::RedisStore) && store.is_a?(::ActiveSupport::Cache::RedisStore)
|
10
|
+
# ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
|
11
|
+
# so use the raw Redis::Store instead
|
12
|
+
store = store.instance_variable_get(:@data)
|
13
|
+
end
|
14
|
+
|
15
|
+
klass = PROXIES.find { |proxy| proxy.handle?(store) }
|
16
|
+
|
17
|
+
klass ? klass.new(store) : store
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Attack
|
5
|
+
module StoreProxy
|
6
|
+
class DalliProxy < SimpleDelegator
|
7
|
+
def self.handle?(store)
|
8
|
+
return false unless defined?(::Dalli)
|
9
|
+
|
10
|
+
# Consider extracting to a separate Connection Pool proxy to reduce
|
11
|
+
# code here and handle clients other than Dalli.
|
12
|
+
if defined?(::ConnectionPool) && store.is_a?(::ConnectionPool)
|
13
|
+
store.with { |conn| conn.is_a?(::Dalli::Client) }
|
14
|
+
else
|
15
|
+
store.is_a?(::Dalli::Client)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(client)
|
20
|
+
super(client)
|
21
|
+
stub_with_if_missing
|
22
|
+
end
|
23
|
+
|
24
|
+
def read(key)
|
25
|
+
with do |client|
|
26
|
+
client.get(key)
|
27
|
+
end
|
28
|
+
rescue Dalli::DalliError
|
29
|
+
end
|
30
|
+
|
31
|
+
def write(key, value, options={})
|
32
|
+
with do |client|
|
33
|
+
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
|
34
|
+
end
|
35
|
+
rescue Dalli::DalliError
|
36
|
+
end
|
37
|
+
|
38
|
+
def increment(key, amount, options={})
|
39
|
+
with do |client|
|
40
|
+
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
|
41
|
+
end
|
42
|
+
rescue Dalli::DalliError
|
43
|
+
end
|
44
|
+
|
45
|
+
def delete(key)
|
46
|
+
with do |client|
|
47
|
+
client.delete(key)
|
48
|
+
end
|
49
|
+
rescue Dalli::DalliError
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def stub_with_if_missing
|
55
|
+
unless __getobj__.respond_to?(:with)
|
56
|
+
class << self
|
57
|
+
def with; yield __getobj__; end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Attack
|
5
|
+
module StoreProxy
|
6
|
+
class RedisStoreProxy < SimpleDelegator
|
7
|
+
def self.handle?(store)
|
8
|
+
defined?(::Redis::Store) && store.is_a?(::Redis::Store)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(store)
|
12
|
+
super(store)
|
13
|
+
end
|
14
|
+
|
15
|
+
def raw_read(key)
|
16
|
+
#https://github.com/redis-store/redis-store/issues/96
|
17
|
+
# if raw not specified results in 'marshal data too short' error
|
18
|
+
self.get(key, raw: true)
|
19
|
+
rescue Redis::BaseError
|
20
|
+
end
|
21
|
+
|
22
|
+
def read(key)
|
23
|
+
self.get(key)
|
24
|
+
rescue Redis::BaseError
|
25
|
+
end
|
26
|
+
|
27
|
+
def write(key, value, options={})
|
28
|
+
if (expires_in = options[:expires_in])
|
29
|
+
self.setex(key, expires_in, value)
|
30
|
+
else
|
31
|
+
self.set(key, value)
|
32
|
+
end
|
33
|
+
rescue Redis::BaseError
|
34
|
+
end
|
35
|
+
|
36
|
+
def increment(key, amount, options={})
|
37
|
+
count = nil
|
38
|
+
self.pipelined do
|
39
|
+
count = self.incrby(key, amount)
|
40
|
+
self.expire(key, options[:expires_in]) if options[:expires_in]
|
41
|
+
end
|
42
|
+
count.value if count
|
43
|
+
rescue Redis::BaseError
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Throttle
|
4
|
+
MANDATORY_OPTIONS = [:limit, :period]
|
5
|
+
attr_reader :name, :limit, :period, :block, :type
|
6
|
+
def initialize(name, options, block)
|
7
|
+
@name, @block = name, block
|
8
|
+
MANDATORY_OPTIONS.each do |opt|
|
9
|
+
raise ArgumentError.new("Must pass #{opt.inspect} option") unless options[opt]
|
10
|
+
end
|
11
|
+
@limit = options[:limit]
|
12
|
+
@period = options[:period].to_i
|
13
|
+
@type = options.fetch(:type, :throttle)
|
14
|
+
end
|
15
|
+
|
16
|
+
def cache
|
17
|
+
Rack::Attack.cache
|
18
|
+
end
|
19
|
+
|
20
|
+
def get_count(discriminator)
|
21
|
+
key = "#{name}:#{discriminator}"
|
22
|
+
cache.count(key, period)
|
23
|
+
end
|
24
|
+
|
25
|
+
def [](req)
|
26
|
+
discriminator = block[req]
|
27
|
+
return false unless discriminator
|
28
|
+
|
29
|
+
count = get_count(discriminator)
|
30
|
+
current_limit = limit.respond_to?(:call) ? limit.call(req) : limit
|
31
|
+
data = {
|
32
|
+
:count => count,
|
33
|
+
:period => period,
|
34
|
+
:limit => current_limit
|
35
|
+
}
|
36
|
+
(req.env['rack.attack.throttle_data'] ||= {})[name] = data
|
37
|
+
|
38
|
+
(count > current_limit).tap do |throttled|
|
39
|
+
if throttled
|
40
|
+
req.env['rack.attack.matched'] = name
|
41
|
+
req.env['rack.attack.match_discriminator'] = discriminator
|
42
|
+
req.env['rack.attack.match_type'] = type
|
43
|
+
req.env['rack.attack.match_data'] = data
|
44
|
+
Rack::Attack.instrument(req)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Rack
|
2
|
+
class Attack
|
3
|
+
class Track
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
attr_reader :filter
|
7
|
+
|
8
|
+
def initialize(name, options = {}, block)
|
9
|
+
options[:type] = :track
|
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
|
16
|
+
end
|
17
|
+
|
18
|
+
def_delegator :@filter, :[]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
describe 'Rack::Attack.Allow2Ban' 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::Allow2Ban.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 qualifying request' do
|
24
|
+
describe 'when not at maxretry' do
|
25
|
+
before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
26
|
+
it 'succeeds' do
|
27
|
+
last_response.status.must_equal 200
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'increases fail count' do
|
31
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban: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:allow2ban: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 'succeeds' do
|
49
|
+
last_response.status.must_equal 200
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'increases fail count' do
|
53
|
+
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban: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:allow2ban: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}:allow2ban: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:allow2ban: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}:allow2ban: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:allow2ban:ban:1.2.3.4"
|
116
|
+
@cache.store.read(key).must_equal 1
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|