rack-attack 5.1.0 → 5.2.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 +164 -79
- data/lib/rack/attack.rb +30 -8
- data/lib/rack/attack/cache.rb +24 -10
- data/lib/rack/attack/check.rb +1 -0
- data/lib/rack/attack/version.rb +1 -1
- data/spec/acceptance/allow2ban_spec.rb +71 -0
- data/spec/acceptance/blocking_ip_spec.rb +38 -0
- data/spec/acceptance/blocking_spec.rb +20 -0
- data/spec/acceptance/blocking_subnet_spec.rb +44 -0
- data/spec/acceptance/cache_store_config_for_allow2ban_spec.rb +111 -0
- data/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +108 -0
- data/spec/acceptance/cache_store_config_for_throttle_spec.rb +48 -0
- data/spec/acceptance/cache_store_config_with_rails_spec.rb +31 -0
- data/spec/acceptance/customizing_blocked_response_spec.rb +41 -0
- data/spec/acceptance/customizing_throttled_response_spec.rb +59 -0
- data/spec/acceptance/extending_request_object_spec.rb +34 -0
- data/spec/acceptance/fail2ban_spec.rb +76 -0
- data/spec/acceptance/safelisting_ip_spec.rb +49 -0
- data/spec/acceptance/safelisting_spec.rb +16 -0
- data/spec/acceptance/safelisting_subnet_spec.rb +48 -0
- data/spec/acceptance/throttling_spec.rb +130 -1
- data/spec/acceptance/track_spec.rb +27 -0
- data/spec/acceptance/track_throttle_spec.rb +53 -0
- data/spec/spec_helper.rb +12 -0
- metadata +60 -4
- data/spec/rack_attack_store_config_spec.rb +0 -20
data/lib/rack/attack/cache.rb
CHANGED
@@ -20,6 +20,9 @@ module Rack
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def read(unprefixed_key)
|
23
|
+
enforce_store_presence!
|
24
|
+
enforce_store_method_presence!(:read)
|
25
|
+
|
23
26
|
store.read("#{prefix}:#{unprefixed_key}")
|
24
27
|
end
|
25
28
|
|
@@ -46,18 +49,29 @@ module Rack
|
|
46
49
|
end
|
47
50
|
|
48
51
|
def do_count(key, expires_in)
|
52
|
+
enforce_store_presence!
|
53
|
+
enforce_store_method_presence!(:increment)
|
54
|
+
|
55
|
+
result = store.increment(key, 1, :expires_in => expires_in)
|
56
|
+
|
57
|
+
# NB: Some stores return nil when incrementing uninitialized values
|
58
|
+
if result.nil?
|
59
|
+
enforce_store_method_presence!(:write)
|
60
|
+
|
61
|
+
store.write(key, 1, :expires_in => expires_in)
|
62
|
+
end
|
63
|
+
result || 1
|
64
|
+
end
|
65
|
+
|
66
|
+
def enforce_store_presence!
|
49
67
|
if store.nil?
|
50
68
|
raise Rack::Attack::MissingStoreError
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
if result.nil?
|
58
|
-
store.write(key, 1, :expires_in => expires_in)
|
59
|
-
end
|
60
|
-
result || 1
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def enforce_store_method_presence!(method_name)
|
73
|
+
if !store.respond_to?(method_name)
|
74
|
+
raise Rack::Attack::MisconfiguredStoreError, "Store needs to respond to ##{method_name}"
|
61
75
|
end
|
62
76
|
end
|
63
77
|
end
|
data/lib/rack/attack/check.rb
CHANGED
data/lib/rack/attack/version.rb
CHANGED
@@ -0,0 +1,71 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
require "timecop"
|
3
|
+
|
4
|
+
describe "allow2ban" do
|
5
|
+
before do
|
6
|
+
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
7
|
+
|
8
|
+
Rack::Attack.blocklist("allow2ban pentesters") do |request|
|
9
|
+
Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do
|
10
|
+
request.path.include?("scarce-resource")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
it "returns OK for many requests that doesn't match the filter" do
|
16
|
+
get "/"
|
17
|
+
assert_equal 200, last_response.status
|
18
|
+
|
19
|
+
get "/"
|
20
|
+
assert_equal 200, last_response.status
|
21
|
+
end
|
22
|
+
|
23
|
+
it "returns OK for first request that matches the filter" do
|
24
|
+
get "/scarce-resource"
|
25
|
+
assert_equal 200, last_response.status
|
26
|
+
end
|
27
|
+
|
28
|
+
it "forbids all access after reaching maxretry limit" do
|
29
|
+
get "/scarce-resource"
|
30
|
+
assert_equal 200, last_response.status
|
31
|
+
|
32
|
+
get "/scarce-resource"
|
33
|
+
assert_equal 200, last_response.status
|
34
|
+
|
35
|
+
get "/scarce-resource"
|
36
|
+
assert_equal 403, last_response.status
|
37
|
+
|
38
|
+
get "/"
|
39
|
+
assert_equal 403, last_response.status
|
40
|
+
end
|
41
|
+
|
42
|
+
it "restores access after bantime elapsed" do
|
43
|
+
get "/scarce-resource"
|
44
|
+
assert_equal 200, last_response.status
|
45
|
+
|
46
|
+
get "/scarce-resource"
|
47
|
+
assert_equal 200, last_response.status
|
48
|
+
|
49
|
+
get "/"
|
50
|
+
assert_equal 403, last_response.status
|
51
|
+
|
52
|
+
Timecop.travel(60) do
|
53
|
+
get "/"
|
54
|
+
|
55
|
+
assert_equal 200, last_response.status
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
it "does not forbid all access if maxrety condition is met but not within the findtime timespan" do
|
60
|
+
get "/scarce-resource"
|
61
|
+
assert_equal 200, last_response.status
|
62
|
+
|
63
|
+
Timecop.travel(31) do
|
64
|
+
get "/scarce-resource"
|
65
|
+
assert_equal 200, last_response.status
|
66
|
+
|
67
|
+
get "/"
|
68
|
+
assert_equal 200, last_response.status
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
describe "Blocking an IP" do
|
4
|
+
before do
|
5
|
+
Rack::Attack.blocklist_ip("1.2.3.4")
|
6
|
+
end
|
7
|
+
|
8
|
+
it "forbids request if IP matches" do
|
9
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
10
|
+
|
11
|
+
assert_equal 403, last_response.status
|
12
|
+
end
|
13
|
+
|
14
|
+
it "succeeds if IP doesn't match" do
|
15
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
16
|
+
|
17
|
+
assert_equal 200, last_response.status
|
18
|
+
end
|
19
|
+
|
20
|
+
it "notifies when the request is blocked" do
|
21
|
+
notified = false
|
22
|
+
notification_type = nil
|
23
|
+
|
24
|
+
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
|
25
|
+
notified = true
|
26
|
+
notification_type = request.env["rack.attack.match_type"]
|
27
|
+
end
|
28
|
+
|
29
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
30
|
+
|
31
|
+
refute notified
|
32
|
+
|
33
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
34
|
+
|
35
|
+
assert notified
|
36
|
+
assert_equal :blocklist, notification_type
|
37
|
+
end
|
38
|
+
end
|
@@ -18,4 +18,24 @@ describe "#blocklist" do
|
|
18
18
|
|
19
19
|
assert_equal 200, last_response.status
|
20
20
|
end
|
21
|
+
|
22
|
+
it "notifies when the request is blocked" do
|
23
|
+
notification_matched = nil
|
24
|
+
notification_type = nil
|
25
|
+
|
26
|
+
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
|
27
|
+
notification_matched = request.env["rack.attack.matched"]
|
28
|
+
notification_type = request.env["rack.attack.match_type"]
|
29
|
+
end
|
30
|
+
|
31
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
32
|
+
|
33
|
+
assert_nil notification_matched
|
34
|
+
assert_nil notification_type
|
35
|
+
|
36
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
37
|
+
|
38
|
+
assert_equal "block 1.2.3.4", notification_matched
|
39
|
+
assert_equal :blocklist, notification_type
|
40
|
+
end
|
21
41
|
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
describe "Blocking an IP subnet" do
|
4
|
+
before do
|
5
|
+
Rack::Attack.blocklist_ip("1.2.3.4/31")
|
6
|
+
end
|
7
|
+
|
8
|
+
it "forbids request if IP is inside the subnet" do
|
9
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
10
|
+
|
11
|
+
assert_equal 403, last_response.status
|
12
|
+
end
|
13
|
+
|
14
|
+
it "forbids request for another IP in the subnet" do
|
15
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.5"
|
16
|
+
|
17
|
+
assert_equal 403, last_response.status
|
18
|
+
end
|
19
|
+
|
20
|
+
it "succeeds if IP is outside the subnet" do
|
21
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.6"
|
22
|
+
|
23
|
+
assert_equal 200, last_response.status
|
24
|
+
end
|
25
|
+
|
26
|
+
it "notifies when the request is blocked" do
|
27
|
+
notified = false
|
28
|
+
notification_type = nil
|
29
|
+
|
30
|
+
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request|
|
31
|
+
notified = true
|
32
|
+
notification_type = request.env["rack.attack.match_type"]
|
33
|
+
end
|
34
|
+
|
35
|
+
get "/", {}, "REMOTE_ADDR" => "5.6.7.8"
|
36
|
+
|
37
|
+
refute notified
|
38
|
+
|
39
|
+
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
|
40
|
+
|
41
|
+
assert notified
|
42
|
+
assert_equal :blocklist, notification_type
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
describe "Cache store config when using allow2ban" do
|
4
|
+
before do
|
5
|
+
Rack::Attack.blocklist("allow2ban pentesters") do |request|
|
6
|
+
Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do
|
7
|
+
request.path.include?("scarce-resource")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
it "gives semantic error if no store was configured" do
|
13
|
+
assert_raises(Rack::Attack::MissingStoreError) do
|
14
|
+
get "/scarce-resource"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
it "gives semantic error if store is missing #read method" do
|
19
|
+
basic_store_class = Class.new do
|
20
|
+
def write(key, value)
|
21
|
+
end
|
22
|
+
|
23
|
+
def increment(key, count, options = {})
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
Rack::Attack.cache.store = basic_store_class.new
|
28
|
+
|
29
|
+
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
|
30
|
+
get "/scarce-resource"
|
31
|
+
end
|
32
|
+
|
33
|
+
assert_equal "Store needs to respond to #read", raised_exception.message
|
34
|
+
end
|
35
|
+
|
36
|
+
it "gives semantic error if store is missing #write method" do
|
37
|
+
basic_store_class = Class.new do
|
38
|
+
def read(key)
|
39
|
+
end
|
40
|
+
|
41
|
+
def increment(key, count, options = {})
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
Rack::Attack.cache.store = basic_store_class.new
|
46
|
+
|
47
|
+
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
|
48
|
+
get "/scarce-resource"
|
49
|
+
end
|
50
|
+
|
51
|
+
assert_equal "Store needs to respond to #write", raised_exception.message
|
52
|
+
end
|
53
|
+
|
54
|
+
it "gives semantic error if store is missing #increment method" do
|
55
|
+
basic_store_class = Class.new do
|
56
|
+
def read(key)
|
57
|
+
end
|
58
|
+
|
59
|
+
def write(key, value)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
Rack::Attack.cache.store = basic_store_class.new
|
64
|
+
|
65
|
+
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
|
66
|
+
get "/scarce-resource"
|
67
|
+
end
|
68
|
+
|
69
|
+
assert_equal "Store needs to respond to #increment", raised_exception.message
|
70
|
+
end
|
71
|
+
|
72
|
+
it "works with any object that responds to #read, #write and #increment" do
|
73
|
+
basic_store_class = Class.new do
|
74
|
+
attr_accessor :backend
|
75
|
+
|
76
|
+
def initialize
|
77
|
+
@backend = {}
|
78
|
+
end
|
79
|
+
|
80
|
+
def read(key)
|
81
|
+
@backend[key]
|
82
|
+
end
|
83
|
+
|
84
|
+
def write(key, value, options = {})
|
85
|
+
@backend[key] = value
|
86
|
+
end
|
87
|
+
|
88
|
+
def increment(key, count, options = {})
|
89
|
+
@backend[key] ||= 0
|
90
|
+
@backend[key] += 1
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
Rack::Attack.cache.store = basic_store_class.new
|
95
|
+
|
96
|
+
get "/"
|
97
|
+
assert_equal 200, last_response.status
|
98
|
+
|
99
|
+
get "/scarce-resource"
|
100
|
+
assert_equal 200, last_response.status
|
101
|
+
|
102
|
+
get "/scarce-resource"
|
103
|
+
assert_equal 200, last_response.status
|
104
|
+
|
105
|
+
get "/scarce-resource"
|
106
|
+
assert_equal 403, last_response.status
|
107
|
+
|
108
|
+
get "/"
|
109
|
+
assert_equal 403, last_response.status
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
describe "Cache store config when using fail2ban" do
|
4
|
+
before do
|
5
|
+
Rack::Attack.blocklist("fail2ban pentesters") do |request|
|
6
|
+
Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do
|
7
|
+
request.path.include?("private-place")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
it "gives semantic error if no store was configured" do
|
13
|
+
assert_raises(Rack::Attack::MissingStoreError) do
|
14
|
+
get "/private-place"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
it "gives semantic error if store is missing #read method" do
|
19
|
+
basic_store_class = Class.new do
|
20
|
+
def write(key, value)
|
21
|
+
end
|
22
|
+
|
23
|
+
def increment(key, count, options = {})
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
Rack::Attack.cache.store = basic_store_class.new
|
28
|
+
|
29
|
+
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
|
30
|
+
get "/private-place"
|
31
|
+
end
|
32
|
+
|
33
|
+
assert_equal "Store needs to respond to #read", raised_exception.message
|
34
|
+
end
|
35
|
+
|
36
|
+
it "gives semantic error if store is missing #write method" do
|
37
|
+
basic_store_class = Class.new do
|
38
|
+
def read(key)
|
39
|
+
end
|
40
|
+
|
41
|
+
def increment(key, count, options = {})
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
Rack::Attack.cache.store = basic_store_class.new
|
46
|
+
|
47
|
+
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
|
48
|
+
get "/private-place"
|
49
|
+
end
|
50
|
+
|
51
|
+
assert_equal "Store needs to respond to #write", raised_exception.message
|
52
|
+
end
|
53
|
+
|
54
|
+
it "gives semantic error if store is missing #increment method" do
|
55
|
+
basic_store_class = Class.new do
|
56
|
+
def read(key)
|
57
|
+
end
|
58
|
+
|
59
|
+
def write(key, value)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
Rack::Attack.cache.store = basic_store_class.new
|
64
|
+
|
65
|
+
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
|
66
|
+
get "/private-place"
|
67
|
+
end
|
68
|
+
|
69
|
+
assert_equal "Store needs to respond to #increment", raised_exception.message
|
70
|
+
end
|
71
|
+
|
72
|
+
it "works with any object that responds to #read, #write and #increment" do
|
73
|
+
basic_store_class = Class.new do
|
74
|
+
attr_accessor :backend
|
75
|
+
|
76
|
+
def initialize
|
77
|
+
@backend = {}
|
78
|
+
end
|
79
|
+
|
80
|
+
def read(key)
|
81
|
+
@backend[key]
|
82
|
+
end
|
83
|
+
|
84
|
+
def write(key, value, options = {})
|
85
|
+
@backend[key] = value
|
86
|
+
end
|
87
|
+
|
88
|
+
def increment(key, count, options = {})
|
89
|
+
@backend[key] ||= 0
|
90
|
+
@backend[key] += 1
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
Rack::Attack.cache.store = basic_store_class.new
|
95
|
+
|
96
|
+
get "/"
|
97
|
+
assert_equal 200, last_response.status
|
98
|
+
|
99
|
+
get "/private-place"
|
100
|
+
assert_equal 403, last_response.status
|
101
|
+
|
102
|
+
get "/private-place"
|
103
|
+
assert_equal 403, last_response.status
|
104
|
+
|
105
|
+
get "/"
|
106
|
+
assert_equal 403, last_response.status
|
107
|
+
end
|
108
|
+
end
|