rack-attack 6.0.0 → 6.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f85825803ce676e10466175d4bb99cc151d649130d6f4008bdffdc1381b4650a
4
- data.tar.gz: 2bcd9d6a9d75491df5a9ddf2f7a5128eae152245fce8f27a069f36037ebc8ddb
3
+ metadata.gz: cba47e380843d184fd3df1af08b252aca3fc2411de7ccbd62a9b7da6ee933b72
4
+ data.tar.gz: 0dc5300a553830ca7e1cfa84de814fd743e608b9ec0140fd6b1145c421085ba7
5
5
  SHA512:
6
- metadata.gz: 5c9b03bbb0e55105ebe4d2ea9a0f13a025ea7ab4903f86e07956ecbd7f7ddfcacfecbbc8239bc9ac427fcc4bd5445fa85e20732bd4d90b51f6dafeb7194bc063
7
- data.tar.gz: a31dad7fb5c9220d4a44b303103ffb12cb23843bc150b14cec38a9d0151bc73791a66a80e8a543fa09f58d36a7b0627f0b5fda4fb93953d525747840d7e1d97f
6
+ metadata.gz: 71fd5eace9c851dab06317f3f4f4f28a2cbc20dd20c663228fbd073a052b4f30c4de87a06109ce9cf6b7395fc547b0c99b6f4e579285a699be40a93a9511452f
7
+ data.tar.gz: 5139f0be932f94273dba2d8d59ccbcb0e14ca92656b68d126099a124f04160c2c426e72f330b9e3c5dd8ed560f2fb292aa68dde2c32f2238d36c8c7e95422d01
data/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ __Note__: You are viewing the development version README.
2
+ For the README consistent with the latest released version see https://github.com/kickstarter/rack-attack/blob/6-stable/README.md.
3
+
1
4
  # Rack::Attack
2
5
 
3
6
  *Rack middleware for blocking & throttling abusive requests*
@@ -9,6 +12,7 @@ See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-ha
9
12
  [![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](https://badge.fury.io/rb/rack-attack)
10
13
  [![Build Status](https://travis-ci.org/kickstarter/rack-attack.svg?branch=master)](https://travis-ci.org/kickstarter/rack-attack)
11
14
  [![Code Climate](https://codeclimate.com/github/kickstarter/rack-attack.svg)](https://codeclimate.com/github/kickstarter/rack-attack)
15
+ [![Join the chat at https://gitter.im/rack-attack/rack-attack](https://badges.gitter.im/rack-attack/rack-attack.svg)](https://gitter.im/rack-attack/rack-attack)
12
16
 
13
17
  ## Table of contents
14
18
 
@@ -67,14 +71,19 @@ Or install it yourself as:
67
71
 
68
72
  Then tell your ruby web application to use rack-attack as a middleware.
69
73
 
70
- a) For __rails__ applications:
71
-
74
+ a) For __rails__ applications with versions >= 5.1 it is used by default. For older rails versions you should enable it explicitly:
72
75
  ```ruby
73
76
  # In config/application.rb
74
77
 
75
78
  config.middleware.use Rack::Attack
76
79
  ```
77
80
 
81
+ You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing:
82
+
83
+ ```ruby
84
+ Rack::Attack.enabled = false
85
+ ```
86
+
78
87
  b) For __rack__ applications:
79
88
 
80
89
  ```ruby
@@ -285,9 +294,9 @@ Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
285
294
  end
286
295
 
287
296
  # Track it using ActiveSupport::Notification
288
- ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, payload|
297
+ ActiveSupport::Notifications.subscribe("track.rack_attack") do |name, start, finish, request_id, payload|
289
298
  req = payload[:request]
290
- if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track
299
+ if req.env['rack.attack.matched'] == "special_agent"
291
300
  Rails.logger.info "special_agent: #{req.path}"
292
301
  STATSD.increment("special_agent")
293
302
  end
@@ -333,6 +342,11 @@ end
333
342
  While Rack::Attack's primary focus is minimizing harm from abusive clients, it
334
343
  can also be used to return rate limit data that's helpful for well-behaved clients.
335
344
 
345
+ If you want to return to user how many seconds to wait until he can start sending requests again, this can be done through enabling `Retry-After` header:
346
+ ```ruby
347
+ Rack::Attack.throttled_response_retry_after_header = true
348
+ ```
349
+
336
350
  Here's an example response that includes conventional `RateLimit-*` headers:
337
351
 
338
352
  ```ruby
@@ -354,7 +368,7 @@ end
354
368
  For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
355
369
 
356
370
  ```ruby
357
- request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t }
371
+ request.env['rack.attack.throttle_data'][name] # => { discriminator: d, count: n, period: p, limit: l, epoch_time: t }
358
372
  ```
359
373
 
360
374
  ## Logging & Instrumentation
@@ -2,163 +2,114 @@
2
2
 
3
3
  require 'rack'
4
4
  require 'forwardable'
5
+ require 'rack/attack/cache'
6
+ require 'rack/attack/configuration'
5
7
  require 'rack/attack/path_normalizer'
6
8
  require 'rack/attack/request'
7
- require "ipaddr"
8
-
9
- class Rack::Attack
10
- class MisconfiguredStoreError < StandardError; end
11
- class MissingStoreError < StandardError; end
12
-
13
- autoload :Cache, 'rack/attack/cache'
14
- autoload :Check, 'rack/attack/check'
15
- autoload :Throttle, 'rack/attack/throttle'
16
- autoload :Safelist, 'rack/attack/safelist'
17
- autoload :Blocklist, 'rack/attack/blocklist'
18
- autoload :Track, 'rack/attack/track'
19
- autoload :StoreProxy, 'rack/attack/store_proxy'
20
- autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
21
- autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy'
22
- autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
23
- autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
24
- autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
25
- autoload :ActiveSupportRedisStoreProxy, 'rack/attack/store_proxy/active_support_redis_store_proxy'
26
- autoload :Fail2Ban, 'rack/attack/fail2ban'
27
- autoload :Allow2Ban, 'rack/attack/allow2ban'
28
-
29
- class << self
30
- attr_accessor :notifier, :blocklisted_response, :throttled_response, :anonymous_blocklists, :anonymous_safelists
31
-
32
- def safelist(name = nil, &block)
33
- safelist = Safelist.new(name, &block)
34
-
35
- if name
36
- safelists[name] = safelist
37
- else
38
- anonymous_safelists << safelist
39
- end
40
- end
41
9
 
42
- def blocklist(name = nil, &block)
43
- blocklist = Blocklist.new(name, &block)
44
-
45
- if name
46
- blocklists[name] = blocklist
47
- else
48
- anonymous_blocklists << blocklist
10
+ require 'rack/attack/railtie' if defined?(::Rails)
11
+
12
+ module Rack
13
+ class Attack
14
+ class Error < StandardError; end
15
+ class MisconfiguredStoreError < Error; end
16
+ class MissingStoreError < Error; end
17
+ class IncompatibleStoreError < Error; end
18
+
19
+ autoload :Check, 'rack/attack/check'
20
+ autoload :Throttle, 'rack/attack/throttle'
21
+ autoload :Safelist, 'rack/attack/safelist'
22
+ autoload :Blocklist, 'rack/attack/blocklist'
23
+ autoload :Track, 'rack/attack/track'
24
+ autoload :StoreProxy, 'rack/attack/store_proxy'
25
+ autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
26
+ autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy'
27
+ autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
28
+ autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
29
+ autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
30
+ autoload :ActiveSupportRedisStoreProxy, 'rack/attack/store_proxy/active_support_redis_store_proxy'
31
+ autoload :Fail2Ban, 'rack/attack/fail2ban'
32
+ autoload :Allow2Ban, 'rack/attack/allow2ban'
33
+
34
+ class << self
35
+ attr_accessor :enabled, :notifier
36
+ attr_reader :configuration
37
+
38
+ def instrument(request)
39
+ if notifier
40
+ event_type = request.env["rack.attack.match_type"]
41
+ notifier.instrument("#{event_type}.rack_attack", request: request)
42
+
43
+ # Deprecated: Keeping just for backwards compatibility
44
+ notifier.instrument("rack.attack", request: request)
45
+ end
49
46
  end
50
- end
51
-
52
- def blocklist_ip(ip_address)
53
- anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
54
- end
55
-
56
- def safelist_ip(ip_address)
57
- anonymous_safelists << Safelist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
58
- end
59
-
60
- def throttle(name, options, &block)
61
- throttles[name] = Throttle.new(name, options, &block)
62
- end
63
-
64
- def track(name, options = {}, &block)
65
- tracks[name] = Track.new(name, options, &block)
66
- end
67
-
68
- def safelists
69
- @safelists ||= {}
70
- end
71
-
72
- def blocklists
73
- @blocklists ||= {}
74
- end
75
-
76
- def throttles
77
- @throttles ||= {}
78
- end
79
-
80
- def tracks
81
- @tracks ||= {}
82
- end
83
-
84
- def safelisted?(request)
85
- anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
86
- safelists.any? { |_name, safelist| safelist.matched_by?(request) }
87
- end
88
47
 
89
- def blocklisted?(request)
90
- anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
91
- blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
92
- end
93
-
94
- def throttled?(request)
95
- throttles.any? do |_name, throttle|
96
- throttle.matched_by?(request)
48
+ def cache
49
+ @cache ||= Cache.new
97
50
  end
98
- end
99
51
 
100
- def tracked?(request)
101
- tracks.each_value do |track|
102
- track.matched_by?(request)
52
+ def clear!
53
+ warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
54
+ @configuration.clear_configuration
103
55
  end
104
- end
105
-
106
- def instrument(request)
107
- if notifier
108
- event_type = request.env["rack.attack.match_type"]
109
- notifier.instrument("#{event_type}.rack_attack", request: request)
110
56
 
111
- # Deprecated: Keeping just for backwards compatibility
112
- notifier.instrument("rack.attack", request: request)
57
+ def reset!
58
+ cache.reset!
113
59
  end
114
- end
115
-
116
- def cache
117
- @cache ||= Cache.new
118
- end
119
60
 
120
- def clear_configuration
121
- @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {}
122
- self.anonymous_blocklists = []
123
- self.anonymous_safelists = []
124
- end
125
-
126
- def clear!
127
- warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
128
- clear_configuration
129
- end
130
- end
131
-
132
- # Set defaults
133
- @anonymous_blocklists = []
134
- @anonymous_safelists = []
135
- @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
136
- @blocklisted_response = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] }
137
- @throttled_response = lambda { |env|
138
- retry_after = (env['rack.attack.match_data'] || {})[:period]
139
- [429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]]
140
- }
141
-
142
- def initialize(app)
143
- @app = app
144
- end
145
-
146
- def call(env)
147
- env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
148
- request = Rack::Attack::Request.new(env)
149
-
150
- if safelisted?(request)
151
- @app.call(env)
152
- elsif blocklisted?(request)
153
- self.class.blocklisted_response.call(env)
154
- elsif throttled?(request)
155
- self.class.throttled_response.call(env)
156
- else
157
- tracked?(request)
158
- @app.call(env)
61
+ extend Forwardable
62
+ def_delegators(
63
+ :@configuration,
64
+ :safelist,
65
+ :blocklist,
66
+ :blocklist_ip,
67
+ :safelist_ip,
68
+ :throttle,
69
+ :track,
70
+ :blocklisted_response,
71
+ :blocklisted_response=,
72
+ :throttled_response,
73
+ :throttled_response=,
74
+ :throttled_response_retry_after_header,
75
+ :throttled_response_retry_after_header=,
76
+ :clear_configuration,
77
+ :safelists,
78
+ :blocklists,
79
+ :throttles,
80
+ :tracks
81
+ )
82
+ end
83
+
84
+ # Set defaults
85
+ @enabled = true
86
+ @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
87
+ @configuration = Configuration.new
88
+
89
+ attr_reader :configuration
90
+
91
+ def initialize(app)
92
+ @app = app
93
+ @configuration = self.class.configuration
94
+ end
95
+
96
+ def call(env)
97
+ return @app.call(env) if !self.class.enabled || env["rack.attack.called"]
98
+
99
+ env["rack.attack.called"] = true
100
+ env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
101
+ request = Rack::Attack::Request.new(env)
102
+
103
+ if configuration.safelisted?(request)
104
+ @app.call(env)
105
+ elsif configuration.blocklisted?(request)
106
+ configuration.blocklisted_response.call(env)
107
+ elsif configuration.throttled?(request)
108
+ configuration.throttled_response.call(env)
109
+ else
110
+ configuration.tracked?(request)
111
+ @app.call(env)
112
+ end
159
113
  end
160
114
  end
161
-
162
- extend Forwardable
163
- def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked?
164
115
  end
@@ -41,6 +41,17 @@ module Rack
41
41
  store.delete("#{prefix}:#{unprefixed_key}")
42
42
  end
43
43
 
44
+ def reset!
45
+ if store.respond_to?(:delete_matched)
46
+ store.delete_matched("#{prefix}*")
47
+ else
48
+ raise(
49
+ Rack::Attack::IncompatibleStoreError,
50
+ "Configured store #{store.class.name} doesn't respond to #delete_matched method"
51
+ )
52
+ end
53
+ end
54
+
44
55
  private
45
56
 
46
57
  def key_and_expiry(unprefixed_key, period)
@@ -73,7 +84,10 @@ module Rack
73
84
 
74
85
  def enforce_store_method_presence!(method_name)
75
86
  if !store.respond_to?(method_name)
76
- raise Rack::Attack::MisconfiguredStoreError, "Configured store #{store.class.name} doesn't respond to ##{method_name} method"
87
+ raise(
88
+ Rack::Attack::MisconfiguredStoreError,
89
+ "Configured store #{store.class.name} doesn't respond to ##{method_name} method"
90
+ )
77
91
  end
78
92
  end
79
93
  end
@@ -5,7 +5,8 @@ module Rack
5
5
  class Check
6
6
  attr_reader :name, :block, :type
7
7
  def initialize(name, options = {}, &block)
8
- @name, @block = name, block
8
+ @name = name
9
+ @block = block
9
10
  @type = options.fetch(:type, nil)
10
11
  end
11
12
 
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Rack
6
+ class Attack
7
+ class Configuration
8
+ DEFAULT_BLOCKLISTED_RESPONSE = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] }
9
+
10
+ DEFAULT_THROTTLED_RESPONSE = lambda do |env|
11
+ if Rack::Attack.configuration.throttled_response_retry_after_header
12
+ match_data = env['rack.attack.match_data']
13
+ now = match_data[:epoch_time]
14
+ retry_after = match_data[:period] - (now % match_data[:period])
15
+
16
+ [429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]]
17
+ else
18
+ [429, { 'Content-Type' => 'text/plain' }, ["Retry later\n"]]
19
+ end
20
+ end
21
+
22
+ attr_reader :safelists, :blocklists, :throttles, :anonymous_blocklists, :anonymous_safelists
23
+ attr_accessor :blocklisted_response, :throttled_response, :throttled_response_retry_after_header
24
+
25
+ def initialize
26
+ set_defaults
27
+ end
28
+
29
+ def safelist(name = nil, &block)
30
+ safelist = Safelist.new(name, &block)
31
+
32
+ if name
33
+ @safelists[name] = safelist
34
+ else
35
+ @anonymous_safelists << safelist
36
+ end
37
+ end
38
+
39
+ def blocklist(name = nil, &block)
40
+ blocklist = Blocklist.new(name, &block)
41
+
42
+ if name
43
+ @blocklists[name] = blocklist
44
+ else
45
+ @anonymous_blocklists << blocklist
46
+ end
47
+ end
48
+
49
+ def blocklist_ip(ip_address)
50
+ @anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
51
+ end
52
+
53
+ def safelist_ip(ip_address)
54
+ @anonymous_safelists << Safelist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
55
+ end
56
+
57
+ def throttle(name, options, &block)
58
+ @throttles[name] = Throttle.new(name, options, &block)
59
+ end
60
+
61
+ def track(name, options = {}, &block)
62
+ @tracks[name] = Track.new(name, options, &block)
63
+ end
64
+
65
+ def safelisted?(request)
66
+ @anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
67
+ @safelists.any? { |_name, safelist| safelist.matched_by?(request) }
68
+ end
69
+
70
+ def blocklisted?(request)
71
+ @anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
72
+ @blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
73
+ end
74
+
75
+ def throttled?(request)
76
+ @throttles.any? do |_name, throttle|
77
+ throttle.matched_by?(request)
78
+ end
79
+ end
80
+
81
+ def tracked?(request)
82
+ @tracks.each_value do |track|
83
+ track.matched_by?(request)
84
+ end
85
+ end
86
+
87
+ def clear_configuration
88
+ set_defaults
89
+ end
90
+
91
+ private
92
+
93
+ def set_defaults
94
+ @safelists = {}
95
+ @blocklists = {}
96
+ @throttles = {}
97
+ @tracks = {}
98
+ @anonymous_blocklists = []
99
+ @anonymous_safelists = []
100
+ @throttled_response_retry_after_header = false
101
+
102
+ @blocklisted_response = DEFAULT_BLOCKLISTED_RESPONSE
103
+ @throttled_response = DEFAULT_THROTTLED_RESPONSE
104
+ end
105
+ end
106
+ end
107
+ end