wkimeria-rack-attack 4.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,12 @@
1
+ module Rack
2
+ class Attack
3
+ class Blacklist < Check
4
+ def initialize(name, block)
5
+ super
6
+ @type = :blacklist
7
+ end
8
+
9
+ end
10
+ end
11
+ end
12
+
@@ -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,5 @@
1
+ module Rack
2
+ class Attack
3
+ VERSION = '4.1.2'
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ module Rack
2
+ class Attack
3
+ class Whitelist < Check
4
+ def initialize(name, block)
5
+ super
6
+ @type = :whitelist
7
+ end
8
+
9
+ end
10
+ end
11
+ 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