rack-attack-6.8.0/0000755000004100000410000000000015076136130013744 5ustar www-datawww-datarack-attack-6.8.0/rack-attack.gemspec0000644000004100000410000001704115076136130017501 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: rack-attack 6.8.0 ruby lib Gem::Specification.new do |s| s.name = "rack-attack".freeze s.version = "6.8.0".freeze s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack-attack/issues", "changelog_uri" => "https://github.com/rack/rack-attack/blob/main/CHANGELOG.md", "source_code_uri" => "https://github.com/rack/rack-attack" } if s.respond_to? :metadata= s.require_paths = ["lib".freeze] s.authors = ["Aaron Suggs".freeze] s.date = "1980-01-02" s.description = "A rack middleware for throttling and blocking abusive requests".freeze s.email = "aaron@ktheory.com".freeze s.files = ["LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "lib/rack/attack.rb".freeze, "lib/rack/attack/allow2ban.rb".freeze, "lib/rack/attack/base_proxy.rb".freeze, "lib/rack/attack/blocklist.rb".freeze, "lib/rack/attack/cache.rb".freeze, "lib/rack/attack/check.rb".freeze, "lib/rack/attack/configuration.rb".freeze, "lib/rack/attack/fail2ban.rb".freeze, "lib/rack/attack/path_normalizer.rb".freeze, "lib/rack/attack/railtie.rb".freeze, "lib/rack/attack/request.rb".freeze, "lib/rack/attack/safelist.rb".freeze, "lib/rack/attack/store_proxy/dalli_proxy.rb".freeze, "lib/rack/attack/store_proxy/mem_cache_store_proxy.rb".freeze, "lib/rack/attack/store_proxy/redis_cache_store_proxy.rb".freeze, "lib/rack/attack/store_proxy/redis_proxy.rb".freeze, "lib/rack/attack/store_proxy/redis_store_proxy.rb".freeze, "lib/rack/attack/throttle.rb".freeze, "lib/rack/attack/track.rb".freeze, "lib/rack/attack/version.rb".freeze, "spec/acceptance/allow2ban_spec.rb".freeze, "spec/acceptance/blocking_ip_spec.rb".freeze, "spec/acceptance/blocking_spec.rb".freeze, "spec/acceptance/blocking_subnet_spec.rb".freeze, "spec/acceptance/cache_store_config_for_allow2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_fail2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_throttle_spec.rb".freeze, "spec/acceptance/cache_store_config_with_rails_spec.rb".freeze, "spec/acceptance/customizing_blocked_response_spec.rb".freeze, "spec/acceptance/customizing_throttled_response_spec.rb".freeze, "spec/acceptance/extending_request_object_spec.rb".freeze, "spec/acceptance/fail2ban_spec.rb".freeze, "spec/acceptance/rails_middleware_spec.rb".freeze, "spec/acceptance/safelisting_ip_spec.rb".freeze, "spec/acceptance/safelisting_spec.rb".freeze, "spec/acceptance/safelisting_subnet_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_spec.rb".freeze, "spec/acceptance/stores/active_support_memory_store_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_spec.rb".freeze, "spec/acceptance/stores/connection_pool_dalli_client_spec.rb".freeze, "spec/acceptance/stores/dalli_client_spec.rb".freeze, "spec/acceptance/stores/redis_spec.rb".freeze, "spec/acceptance/stores/redis_store_spec.rb".freeze, "spec/acceptance/throttling_spec.rb".freeze, "spec/acceptance/track_spec.rb".freeze, "spec/acceptance/track_throttle_spec.rb".freeze, "spec/allow2ban_spec.rb".freeze, "spec/configuration_spec.rb".freeze, "spec/fail2ban_spec.rb".freeze, "spec/integration/offline_spec.rb".freeze, "spec/rack_attack_dalli_proxy_spec.rb".freeze, "spec/rack_attack_instrumentation_spec.rb".freeze, "spec/rack_attack_path_normalizer_spec.rb".freeze, "spec/rack_attack_request_spec.rb".freeze, "spec/rack_attack_reset_spec.rb".freeze, "spec/rack_attack_spec.rb".freeze, "spec/rack_attack_throttle_spec.rb".freeze, "spec/rack_attack_track_spec.rb".freeze, "spec/spec_helper.rb".freeze, "spec/support/cache_store_helper.rb".freeze, "spec/support/freeze_time_helper.rb".freeze] s.homepage = "https://github.com/rack/rack-attack".freeze s.licenses = ["MIT".freeze] s.rdoc_options = ["--charset=UTF-8".freeze] s.required_ruby_version = Gem::Requirement.new(">= 2.4".freeze) s.rubygems_version = "3.6.9".freeze s.summary = "Block & throttle abusive requests".freeze s.test_files = ["spec/acceptance/allow2ban_spec.rb".freeze, "spec/acceptance/blocking_ip_spec.rb".freeze, "spec/acceptance/blocking_spec.rb".freeze, "spec/acceptance/blocking_subnet_spec.rb".freeze, "spec/acceptance/cache_store_config_for_allow2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_fail2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_throttle_spec.rb".freeze, "spec/acceptance/cache_store_config_with_rails_spec.rb".freeze, "spec/acceptance/customizing_blocked_response_spec.rb".freeze, "spec/acceptance/customizing_throttled_response_spec.rb".freeze, "spec/acceptance/extending_request_object_spec.rb".freeze, "spec/acceptance/fail2ban_spec.rb".freeze, "spec/acceptance/rails_middleware_spec.rb".freeze, "spec/acceptance/safelisting_ip_spec.rb".freeze, "spec/acceptance/safelisting_spec.rb".freeze, "spec/acceptance/safelisting_subnet_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_spec.rb".freeze, "spec/acceptance/stores/active_support_memory_store_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_spec.rb".freeze, "spec/acceptance/stores/connection_pool_dalli_client_spec.rb".freeze, "spec/acceptance/stores/dalli_client_spec.rb".freeze, "spec/acceptance/stores/redis_spec.rb".freeze, "spec/acceptance/stores/redis_store_spec.rb".freeze, "spec/acceptance/throttling_spec.rb".freeze, "spec/acceptance/track_spec.rb".freeze, "spec/acceptance/track_throttle_spec.rb".freeze, "spec/allow2ban_spec.rb".freeze, "spec/configuration_spec.rb".freeze, "spec/fail2ban_spec.rb".freeze, "spec/integration/offline_spec.rb".freeze, "spec/rack_attack_dalli_proxy_spec.rb".freeze, "spec/rack_attack_instrumentation_spec.rb".freeze, "spec/rack_attack_path_normalizer_spec.rb".freeze, "spec/rack_attack_request_spec.rb".freeze, "spec/rack_attack_reset_spec.rb".freeze, "spec/rack_attack_spec.rb".freeze, "spec/rack_attack_throttle_spec.rb".freeze, "spec/rack_attack_track_spec.rb".freeze, "spec/spec_helper.rb".freeze, "spec/support/cache_store_helper.rb".freeze, "spec/support/freeze_time_helper.rb".freeze] s.specification_version = 4 s.add_development_dependency(%q.freeze, [">= 0".freeze]) s.add_development_dependency(%q.freeze, ["~> 2.2".freeze]) s.add_development_dependency(%q.freeze, [">= 1.17".freeze, "< 3.0".freeze]) s.add_development_dependency(%q.freeze, ["~> 11.0".freeze]) s.add_development_dependency(%q.freeze, ["~> 5.11".freeze]) s.add_development_dependency(%q.freeze, ["~> 0.6".freeze]) s.add_runtime_dependency(%q.freeze, [">= 1.0".freeze, "< 4".freeze]) s.add_development_dependency(%q.freeze, ["~> 2.0".freeze]) s.add_development_dependency(%q.freeze, ["~> 13.0".freeze]) s.add_development_dependency(%q.freeze, ["= 1.12.1".freeze]) s.add_development_dependency(%q.freeze, ["~> 0.11.1".freeze]) s.add_development_dependency(%q.freeze, ["~> 1.10.2".freeze]) s.add_development_dependency(%q.freeze, ["~> 0.5.1".freeze]) s.add_development_dependency(%q.freeze, ["~> 0.9.1".freeze]) end rack-attack-6.8.0/lib/0000755000004100000410000000000015076136130014512 5ustar www-datawww-datarack-attack-6.8.0/lib/rack/0000755000004100000410000000000015076136130015432 5ustar www-datawww-datarack-attack-6.8.0/lib/rack/attack.rb0000644000004100000410000000763615076136130017242 0ustar www-datawww-data# frozen_string_literal: true require 'rack' require 'forwardable' require 'rack/attack/cache' require 'rack/attack/configuration' require 'rack/attack/path_normalizer' require 'rack/attack/request' require 'rack/attack/store_proxy/dalli_proxy' require 'rack/attack/store_proxy/mem_cache_store_proxy' require 'rack/attack/store_proxy/redis_proxy' require 'rack/attack/store_proxy/redis_store_proxy' require 'rack/attack/store_proxy/redis_cache_store_proxy' require 'rack/attack/railtie' if defined?(::Rails) module Rack class Attack class Error < StandardError; end class MisconfiguredStoreError < Error; end class MissingStoreError < Error; end class IncompatibleStoreError < Error; end autoload :Check, 'rack/attack/check' autoload :Throttle, 'rack/attack/throttle' autoload :Safelist, 'rack/attack/safelist' autoload :Blocklist, 'rack/attack/blocklist' autoload :Track, 'rack/attack/track' autoload :Fail2Ban, 'rack/attack/fail2ban' autoload :Allow2Ban, 'rack/attack/allow2ban' class << self attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer attr_reader :configuration def instrument(request) if notifier event_type = request.env["rack.attack.match_type"] notifier.instrument("#{event_type}.rack_attack", request: request) # Deprecated: Keeping just for backwards compatibility notifier.instrument("rack.attack", request: request) end end def cache @cache ||= Cache.new end def clear! warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead" @configuration.clear_configuration end def reset! cache.reset! end extend Forwardable def_delegators( :@configuration, :safelist, :blocklist, :blocklist_ip, :safelist_ip, :throttle, :track, :throttled_responder, :throttled_responder=, :blocklisted_responder, :blocklisted_responder=, :blocklisted_response, :blocklisted_response=, :throttled_response, :throttled_response=, :throttled_response_retry_after_header, :throttled_response_retry_after_header=, :clear_configuration, :safelists, :blocklists, :throttles, :tracks ) end # Set defaults @enabled = true @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications) @throttle_discriminator_normalizer = lambda do |discriminator| discriminator.to_s.strip.downcase end @configuration = Configuration.new attr_reader :configuration def initialize(app) @app = app @configuration = self.class.configuration end def call(env) return @app.call(env) if !self.class.enabled || env["rack.attack.called"] env["rack.attack.called"] = true env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) request = Rack::Attack::Request.new(env) if configuration.safelisted?(request) @app.call(env) elsif configuration.blocklisted?(request) # Deprecated: Keeping blocklisted_response for backwards compatibility if configuration.blocklisted_response configuration.blocklisted_response.call(env) else configuration.blocklisted_responder.call(request) end elsif configuration.throttled?(request) # Deprecated: Keeping throttled_response for backwards compatibility if configuration.throttled_response configuration.throttled_response.call(env) else configuration.throttled_responder.call(request) end else configuration.tracked?(request) @app.call(env) end end end end rack-attack-6.8.0/lib/rack/attack/0000755000004100000410000000000015076136130016701 5ustar www-datawww-datarack-attack-6.8.0/lib/rack/attack/request.rb0000644000004100000410000000072215076136130020717 0ustar www-datawww-data# frozen_string_literal: true # Rack::Attack::Request is the same as ::Rack::Request by default. # # This is a safe place to add custom helper methods to the request object # through monkey patching: # # class Rack::Attack::Request < ::Rack::Request # def localhost? # ip == "127.0.0.1" # end # end # # Rack::Attack.safelist("localhost") {|req| req.localhost? } # module Rack class Attack class Request < ::Rack::Request end end end rack-attack-6.8.0/lib/rack/attack/configuration.rb0000644000004100000410000000760015076136130022100 0ustar www-datawww-data# frozen_string_literal: true require "ipaddr" module Rack class Attack class Configuration DEFAULT_BLOCKLISTED_RESPONDER = lambda { |_req| [403, { 'content-type' => 'text/plain' }, ["Forbidden\n"]] } DEFAULT_THROTTLED_RESPONDER = lambda do |req| if Rack::Attack.configuration.throttled_response_retry_after_header match_data = req.env['rack.attack.match_data'] now = match_data[:epoch_time] retry_after = match_data[:period] - (now % match_data[:period]) [429, { 'content-type' => 'text/plain', 'retry-after' => retry_after.to_s }, ["Retry later\n"]] else [429, { 'content-type' => 'text/plain' }, ["Retry later\n"]] end end attr_reader :safelists, :blocklists, :throttles, :tracks, :anonymous_blocklists, :anonymous_safelists attr_accessor :blocklisted_responder, :throttled_responder, :throttled_response_retry_after_header attr_reader :blocklisted_response, :throttled_response # Keeping these for backwards compatibility def blocklisted_response=(responder) warn "[DEPRECATION] Rack::Attack.blocklisted_response is deprecated. "\ "Please use Rack::Attack.blocklisted_responder instead." @blocklisted_response = responder end def throttled_response=(responder) warn "[DEPRECATION] Rack::Attack.throttled_response is deprecated. "\ "Please use Rack::Attack.throttled_responder instead" @throttled_response = responder end def initialize set_defaults end def safelist(name = nil, &block) safelist = Safelist.new(name, &block) if name @safelists[name] = safelist else @anonymous_safelists << safelist end end def blocklist(name = nil, &block) blocklist = Blocklist.new(name, &block) if name @blocklists[name] = blocklist else @anonymous_blocklists << blocklist end end def blocklist_ip(ip_address) @anonymous_blocklists << Blocklist.new do |request| request.ip && !request.ip.empty? && IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) end end def safelist_ip(ip_address) @anonymous_safelists << Safelist.new do |request| request.ip && !request.ip.empty? && IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) end end def throttle(name, options, &block) @throttles[name] = Throttle.new(name, options, &block) end def track(name, options = {}, &block) @tracks[name] = Track.new(name, options, &block) end def safelisted?(request) @anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } || @safelists.any? { |_name, safelist| safelist.matched_by?(request) } end def blocklisted?(request) @anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } || @blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) } end def throttled?(request) @throttles.any? do |_name, throttle| throttle.matched_by?(request) end end def tracked?(request) @tracks.each_value do |track| track.matched_by?(request) end end def clear_configuration set_defaults end private def set_defaults @safelists = {} @blocklists = {} @throttles = {} @tracks = {} @anonymous_blocklists = [] @anonymous_safelists = [] @throttled_response_retry_after_header = false @blocklisted_responder = DEFAULT_BLOCKLISTED_RESPONDER @throttled_responder = DEFAULT_THROTTLED_RESPONDER # Deprecated: Keeping these for backwards compatibility @blocklisted_response = nil @throttled_response = nil end end end end rack-attack-6.8.0/lib/rack/attack/throttle.rb0000644000004100000410000000462115076136130021076 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Throttle MANDATORY_OPTIONS = [:limit, :period].freeze attr_reader :name, :limit, :period, :block, :type def initialize(name, options, &block) @name = name @block = block MANDATORY_OPTIONS.each do |opt| raise ArgumentError, "Must pass #{opt.inspect} option" unless options[opt] end @limit = options[:limit] @period = options[:period].respond_to?(:call) ? options[:period] : options[:period].to_i @type = options.fetch(:type, :throttle) end def cache Rack::Attack.cache end def matched_by?(request) discriminator = discriminator_for(request) return false unless discriminator current_period = period_for(request) current_limit = limit_for(request) count = cache.count("#{name}:#{discriminator}", current_period) data = { discriminator: discriminator, count: count, period: current_period, limit: current_limit, epoch_time: cache.last_epoch_time } annotate_request_with_throttle_data(request, data) (count > current_limit).tap do |throttled| if throttled annotate_request_with_matched_data(request, data) Rack::Attack.instrument(request) end end end private def discriminator_for(request) discriminator = block.call(request) if discriminator && Rack::Attack.throttle_discriminator_normalizer discriminator = Rack::Attack.throttle_discriminator_normalizer.call(discriminator) end discriminator end def period_for(request) period.respond_to?(:call) ? period.call(request) : period end def limit_for(request) limit.respond_to?(:call) ? limit.call(request) : limit end def annotate_request_with_throttle_data(request, data) (request.env['rack.attack.throttle_data'] ||= {})[name] = data end def annotate_request_with_matched_data(request, data) request.env['rack.attack.matched'] = name request.env['rack.attack.match_discriminator'] = data[:discriminator] request.env['rack.attack.match_type'] = type request.env['rack.attack.match_data'] = data end end end end rack-attack-6.8.0/lib/rack/attack/blocklist.rb0000644000004100000410000000030415076136130021211 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Blocklist < Check def initialize(name = nil, &block) super @type = :blocklist end end end end rack-attack-6.8.0/lib/rack/attack/store_proxy/0000755000004100000410000000000015076136130021276 5ustar www-datawww-datarack-attack-6.8.0/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb0000644000004100000410000000112715076136130026202 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class MemCacheStoreProxy < BaseProxy def self.handle?(store) defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) && store.is_a?(::ActiveSupport::Cache::MemCacheStore) end def read(name, options = {}) super(name, options.merge!(raw: true)) end def write(name, value, options = {}) super(name, value, options.merge!(raw: true)) end end end end end rack-attack-6.8.0/lib/rack/attack/store_proxy/dalli_proxy.rb0000644000004100000410000000336715076136130024162 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class DalliProxy < BaseProxy def self.handle?(store) return false unless defined?(::Dalli) # Consider extracting to a separate Connection Pool proxy to reduce # code here and handle clients other than Dalli. if defined?(::ConnectionPool) && store.is_a?(::ConnectionPool) store.with { |conn| conn.is_a?(::Dalli::Client) } else store.is_a?(::Dalli::Client) end end def initialize(client) super(client) stub_with_if_missing end def read(key) rescuing do with do |client| client.get(key) end end end def write(key, value, options = {}) rescuing do with do |client| client.set(key, value, options.fetch(:expires_in, 0), raw: true) end end end def increment(key, amount, options = {}) rescuing do with do |client| client.incr(key, amount, options.fetch(:expires_in, 0), amount) end end end def delete(key) rescuing do with do |client| client.delete(key) end end end private def stub_with_if_missing unless __getobj__.respond_to?(:with) class << self def with yield __getobj__ end end end end def rescuing yield rescue Dalli::DalliError nil end end end end end rack-attack-6.8.0/lib/rack/attack/store_proxy/redis_proxy.rb0000644000004100000410000000323215076136130024172 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class RedisProxy < BaseProxy def initialize(*args) if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3") warn 'RackAttack requires Redis gem >= 3.0.0.' end super(*args) end def self.handle?(store) defined?(::Redis) && store.class == ::Redis end def read(key) rescuing { get(key) } end def write(key, value, options = {}) if (expires_in = options[:expires_in]) rescuing { setex(key, expires_in, value) } else rescuing { set(key, value) } end end def increment(key, amount, options = {}) rescuing do pipelined do |redis| redis.incrby(key, amount) redis.expire(key, options[:expires_in]) if options[:expires_in] end.first end end def delete(key, _options = {}) rescuing { del(key) } end def delete_matched(matcher, _options = nil) cursor = "0" source = matcher.source rescuing do # Fetch keys in batches using SCAN to avoid blocking the Redis server. loop do cursor, keys = scan(cursor, match: source, count: 1000) del(*keys) unless keys.empty? break if cursor == "0" end end end private def rescuing yield rescue Redis::BaseConnectionError nil end end end end end rack-attack-6.8.0/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb0000644000004100000410000000235315076136130026534 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class RedisCacheStoreProxy < BaseProxy def self.handle?(store) store.class.name == "ActiveSupport::Cache::RedisCacheStore" end if defined?(::ActiveSupport) && ::ActiveSupport::VERSION::MAJOR < 6 def increment(name, amount = 1, **options) # RedisCacheStore#increment ignores options[:expires_in] in versions prior to 6. # # So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize # the counter. After that we continue using the original RedisCacheStore#increment. if options[:expires_in] && !read(name) write(name, amount, options) amount else super end end end def read(name, options = {}) super(name, options.merge!(raw: true)) end def write(name, value, options = {}) super(name, value, options.merge!(raw: true)) end def delete_matched(matcher, options = nil) super(matcher.source, options) end end end end end rack-attack-6.8.0/lib/rack/attack/store_proxy/redis_store_proxy.rb0000644000004100000410000000117515076136130025412 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/store_proxy/redis_proxy' module Rack class Attack module StoreProxy class RedisStoreProxy < RedisProxy def self.handle?(store) defined?(::Redis::Store) && store.is_a?(::Redis::Store) end def read(key) rescuing { get(key, raw: true) } end def write(key, value, options = {}) if (expires_in = options[:expires_in]) rescuing { setex(key, expires_in, value, raw: true) } else rescuing { set(key, value, raw: true) } end end end end end end rack-attack-6.8.0/lib/rack/attack/path_normalizer.rb0000644000004100000410000000173315076136130022430 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack # When using Rack::Attack with a Rails app, developers expect the request path # to be normalized. In particular, trailing slashes are stripped. # (See # https://github.com/rails/rails/blob/f8edd20/actionpack/lib/action_dispatch/journey/router/utils.rb#L5-L22 # for implementation.) # # Look for an ActionDispatch utility class that Rails folks would expect # to normalize request paths. If unavailable, use a fallback class that # doesn't normalize the path (as a non-Rails rack app developer expects). module FallbackPathNormalizer def self.normalize_path(path) path end end PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils) # For Rails apps ::ActionDispatch::Journey::Router::Utils else FallbackPathNormalizer end end end rack-attack-6.8.0/lib/rack/attack/track.rb0000644000004100000410000000073615076136130020340 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Track attr_reader :filter def initialize(name, options = {}, &block) options[:type] = :track @filter = if options[:limit] && options[:period] Throttle.new(name, options, &block) else Check.new(name, options, &block) end end def matched_by?(request) filter.matched_by?(request) end end end end rack-attack-6.8.0/lib/rack/attack/base_proxy.rb0000644000004100000410000000074015076136130021402 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack class BaseProxy < SimpleDelegator class << self def proxies @@proxies ||= [] end def inherited(klass) super proxies << klass end def lookup(store) proxies.find { |proxy| proxy.handle?(store) } end def handle?(_store) raise NotImplementedError end end end end end rack-attack-6.8.0/lib/rack/attack/safelist.rb0000644000004100000410000000030215076136130021033 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Safelist < Check def initialize(name = nil, &block) super @type = :safelist end end end end rack-attack-6.8.0/lib/rack/attack/check.rb0000644000004100000410000000106315076136130020303 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Check attr_reader :name, :block, :type def initialize(name, options = {}, &block) @name = name @block = block @type = options.fetch(:type, nil) end def matched_by?(request) block.call(request).tap do |match| if match request.env["rack.attack.matched"] = name request.env["rack.attack.match_type"] = type Rack::Attack.instrument(request) end end end end end end rack-attack-6.8.0/lib/rack/attack/version.rb0000644000004100000410000000013215076136130020707 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack VERSION = '6.8.0' end end rack-attack-6.8.0/lib/rack/attack/fail2ban.rb0000644000004100000410000000316015076136130020704 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Fail2Ban class << self def filter(discriminator, options) bantime = options[:bantime] or raise ArgumentError, "Must pass bantime option" findtime = options[:findtime] or raise ArgumentError, "Must pass findtime option" maxretry = options[:maxretry] or raise ArgumentError, "Must pass maxretry option" if banned?(discriminator) # Return true for blocklist true elsif yield fail!(discriminator, bantime, findtime, maxretry) end end def reset(discriminator, options) findtime = options[:findtime] or raise ArgumentError, "Must pass findtime option" cache.reset_count("#{key_prefix}:count:#{discriminator}", findtime) # Clear ban flag just in case it's there cache.delete("#{key_prefix}:ban:#{discriminator}") end def banned?(discriminator) cache.read("#{key_prefix}:ban:#{discriminator}") ? true : false end protected def key_prefix 'fail2ban' end def fail!(discriminator, bantime, findtime, maxretry) count = cache.count("#{key_prefix}:count:#{discriminator}", findtime) if count >= maxretry ban!(discriminator, bantime) end true end private def ban!(discriminator, bantime) cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime) end def cache Rack::Attack.cache end end end end end rack-attack-6.8.0/lib/rack/attack/allow2ban.rb0000644000004100000410000000124615076136130021112 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Allow2Ban < Fail2Ban class << self protected def key_prefix 'allow2ban' end # everything is the same here except we only return true # (blocking the request) if they have tripped the limit. def fail!(discriminator, bantime, findtime, maxretry) count = cache.count("#{key_prefix}:count:#{discriminator}", findtime) if count >= maxretry ban!(discriminator, bantime) end # we may not block them this time, but they're banned for next time false end end end end end rack-attack-6.8.0/lib/rack/attack/railtie.rb0000644000004100000410000000042715076136130020662 0ustar www-datawww-data# frozen_string_literal: true begin require 'rails/railtie' rescue LoadError return end module Rack class Attack class Railtie < ::Rails::Railtie initializer "rack-attack.middleware" do |app| app.middleware.use(Rack::Attack) end end end end rack-attack-6.8.0/lib/rack/attack/cache.rb0000644000004100000410000000546115076136130020277 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Cache attr_accessor :prefix attr_reader :last_epoch_time def self.default_store if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) ::Rails.cache end end def initialize(store: self.class.default_store) self.store = store @prefix = 'rack::attack' end attr_reader :store def store=(store) @store = if (proxy = BaseProxy.lookup(store)) proxy.new(store) else store end end def count(unprefixed_key, period) key, expires_in = key_and_expiry(unprefixed_key, period) do_count(key, expires_in) end def read(unprefixed_key) enforce_store_presence! enforce_store_method_presence!(:read) store.read("#{prefix}:#{unprefixed_key}") end def write(unprefixed_key, value, expires_in) store.write("#{prefix}:#{unprefixed_key}", value, expires_in: expires_in) end def reset_count(unprefixed_key, period) key, _ = key_and_expiry(unprefixed_key, period) store.delete(key) end def delete(unprefixed_key) store.delete("#{prefix}:#{unprefixed_key}") end def reset! if store.respond_to?(:delete_matched) store.delete_matched(/#{prefix}*/) else raise( Rack::Attack::IncompatibleStoreError, "Configured store #{store.class.name} doesn't respond to #delete_matched method" ) end end private def key_and_expiry(unprefixed_key, period) @last_epoch_time = Time.now.to_i # Add 1 to expires_in to avoid timing error: https://github.com/rack/rack-attack/pull/85 expires_in = (period - (@last_epoch_time % period) + 1).to_i ["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in] end def do_count(key, expires_in) enforce_store_presence! enforce_store_method_presence!(:increment) result = store.increment(key, 1, expires_in: expires_in) # NB: Some stores return nil when incrementing uninitialized values if result.nil? enforce_store_method_presence!(:write) store.write(key, 1, expires_in: expires_in) end result || 1 end def enforce_store_presence! if store.nil? raise Rack::Attack::MissingStoreError end end def enforce_store_method_presence!(method_name) if !store.respond_to?(method_name) raise( Rack::Attack::MisconfiguredStoreError, "Configured store #{store.class.name} doesn't respond to ##{method_name} method" ) end end end end end rack-attack-6.8.0/spec/0000755000004100000410000000000015076136130014676 5ustar www-datawww-datarack-attack-6.8.0/spec/integration/0000755000004100000410000000000015076136130017221 5ustar www-datawww-datarack-attack-6.8.0/spec/integration/offline_spec.rb0000644000004100000410000000341315076136130022203 0ustar www-datawww-data# frozen_string_literal: true require 'active_support/cache' require_relative '../spec_helper' OfflineExamples = Minitest::SharedExamples.new do it 'should write' do @cache.write('cache-test-key', 'foobar', 1) end it 'should read' do @cache.read('cache-test-key') end it 'should count' do @cache.count('cache-test-key', 1) end it 'should delete' do @cache.delete('cache-test-key') end end if defined?(Redis) && defined?(ActiveSupport::Cache::RedisCacheStore) && Redis::VERSION >= '4' describe 'when Redis is offline' do include OfflineExamples before do @cache = Rack::Attack::Cache.new # Use presumably unused port for Redis client @cache.store = ActiveSupport::Cache::RedisCacheStore.new(host: '127.0.0.1', port: 3333) end end end if defined?(::Dalli) describe 'when Memcached is offline' do include OfflineExamples before do Dalli.logger.level = Logger::FATAL @cache = Rack::Attack::Cache.new @cache.store = Dalli::Client.new('127.0.0.1:22122') end after do Dalli.logger.level = Logger::INFO end end end if defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) describe 'when Memcached is offline' do include OfflineExamples before do Dalli.logger.level = Logger::FATAL @cache = Rack::Attack::Cache.new @cache.store = ActiveSupport::Cache::MemCacheStore.new('127.0.0.1:22122') end after do Dalli.logger.level = Logger::INFO end end end if defined?(Redis) describe 'when Redis is offline' do include OfflineExamples before do @cache = Rack::Attack::Cache.new # Use presumably unused port for Redis client @cache.store = Redis.new(host: '127.0.0.1', port: 3333) end end end rack-attack-6.8.0/spec/spec_helper.rb0000644000004100000410000000256215076136130017521 0ustar www-datawww-data# frozen_string_literal: true require "bundler/setup" require "logger" require "minitest/autorun" require "minitest/pride" require "rack/test" require "active_support" require "rack/attack" if RUBY_ENGINE == "ruby" require "byebug" end def safe_require(name) require name rescue LoadError nil end safe_require "connection_pool" safe_require "dalli" safe_require "rails" safe_require "redis" safe_require "redis-store" class Minitest::Spec include Rack::Test::Methods before do if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) && Rails.cache.respond_to?(:clear) Rails.cache.clear end end after do Rack::Attack.clear_configuration Rack::Attack.instance_variable_set(:@cache, nil) end def app Rack::Builder.new do # Use Rack::Lint to test that rack-attack is complying with the rack spec use Rack::Lint # Intentionally added twice to test idempotence property use Rack::Attack use Rack::Attack use Rack::Lint run lambda { |_env| [200, {}, ['Hello World']] } end.to_app end def self.it_allows_ok_requests it "must allow ok requests" do get '/', {}, 'REMOTE_ADDR' => '127.0.0.1' _(last_response.status).must_equal 200 _(last_response.body).must_equal 'Hello World' end end end class Minitest::SharedExamples < Module include Minitest::Spec::DSL end rack-attack-6.8.0/spec/rack_attack_request_spec.rb0000644000004100000410000000053315076136130022255 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack' do describe 'helpers' do before do Rack::Attack::Request.define_method :remote_ip do ip end Rack::Attack.safelist('valid IP') do |req| req.remote_ip == "127.0.0.1" end end it_allows_ok_requests end end rack-attack-6.8.0/spec/rack_attack_spec.rb0000644000004100000410000000555515076136130020516 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack' do it_allows_ok_requests describe 'normalizing paths' do before do Rack::Attack.blocklist("banned_path") { |req| req.path == '/foo' } end it 'blocks requests with trailing slash' do if Rack::Attack::PathNormalizer == Rack::Attack::FallbackPathNormalizer skip "Normalization is only present on Rails" end get '/foo/' _(last_response.status).must_equal 403 end end describe 'blocklist' do before do @bad_ip = '1.2.3.4' Rack::Attack.blocklist("ip #{@bad_ip}") { |req| req.ip == @bad_ip } end it 'has a blocklist' do _(Rack::Attack.blocklists.key?("ip #{@bad_ip}")).must_equal true end describe "a bad request" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip } it "should return a blocklist response" do _(last_response.status).must_equal 403 _(last_response.body).must_equal "Forbidden\n" end it "should tag the env" do _(last_request.env['rack.attack.matched']).must_equal "ip #{@bad_ip}" _(last_request.env['rack.attack.match_type']).must_equal :blocklist end it_allows_ok_requests end describe "and safelist" do before do @good_ua = 'GoodUA' Rack::Attack.safelist("good ua") { |req| req.user_agent == @good_ua } end it('has a safelist') { Rack::Attack.safelists.key?("good ua") } describe "with a request match both safelist & blocklist" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua } it "should allow safelists before blocklists" do _(last_response.status).must_equal 200 end it "should tag the env" do _(last_request.env['rack.attack.matched']).must_equal 'good ua' _(last_request.env['rack.attack.match_type']).must_equal :safelist end end end describe '#blocklisted_responder' do it 'should exist' do _(Rack::Attack.blocklisted_responder).must_respond_to :call end end describe '#throttled_responder' do it 'should exist' do _(Rack::Attack.throttled_responder).must_respond_to :call end end end describe 'enabled' do it 'should be enabled by default' do _(Rack::Attack.enabled).must_equal true end it 'should directly pass request when disabled' do bad_ip = '1.2.3.4' Rack::Attack.blocklist("ip #{bad_ip}") { |req| req.ip == bad_ip } get '/', {}, 'REMOTE_ADDR' => bad_ip _(last_response.status).must_equal 403 prev_enabled = Rack::Attack.enabled begin Rack::Attack.enabled = false get '/', {}, 'REMOTE_ADDR' => bad_ip _(last_response.status).must_equal 200 ensure Rack::Attack.enabled = prev_enabled end end end end rack-attack-6.8.0/spec/rack_attack_throttle_spec.rb0000644000004100000410000001345615076136130022442 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' require_relative 'support/freeze_time_helper' describe 'Rack::Attack.throttle' do before do @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |req| req.ip } end it('should have a throttle') { Rack::Attack.throttles.key?('ip/sec') } it_allows_ok_requests describe 'a single request' do it 'should set the counter for one request' do within_same_period do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" _(Rack::Attack.cache.store.read(key)).must_equal 1 end end it 'should populate throttle data' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' data = { count: 1, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" } _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end describe "with 2 requests" do before do within_same_period do 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } end end it 'should block the last request' do _(last_response.status).must_equal 429 end it 'should tag the env' do _(last_request.env['rack.attack.matched']).must_equal 'ip/sec' _(last_request.env['rack.attack.match_type']).must_equal :throttle _(last_request.env['rack.attack.match_data']).must_equal( count: 2, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" ) _(last_request.env['rack.attack.match_discriminator']).must_equal('1.2.3.4') end end end describe 'Rack::Attack.throttle with limit as proc' do before do @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: lambda { |_req| 1 }, period: @period) { |req| req.ip } end it_allows_ok_requests describe 'a single request' do it 'should set the counter for one request' do within_same_period do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" _(Rack::Attack.cache.store.read(key)).must_equal 1 end end it 'should populate throttle data' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' data = { count: 1, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" } _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end end describe 'Rack::Attack.throttle with period as proc' do before do @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: lambda { |_req| 1 }, period: lambda { |_req| @period }) { |req| req.ip } end it_allows_ok_requests describe 'a single request' do it 'should set the counter for one request' do within_same_period do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" _(Rack::Attack.cache.store.read(key)).must_equal 1 end end it 'should populate throttle data' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' data = { count: 1, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" } _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end end describe 'Rack::Attack.throttle with block returning nil' do before do @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |_| nil } end it_allows_ok_requests describe 'a single request' do it 'should not set the counter' do within_same_period do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" assert_nil Rack::Attack.cache.store.read(key) end end it 'should not populate throttle data' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' assert_nil last_request.env['rack.attack.throttle_data'] end end end describe 'Rack::Attack.throttle with throttle_discriminator_normalizer' do before do @period = 60 @emails = [ "person@example.com", "PERSON@example.com ", " person@example.com\r\n ", ] Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('logins/email', limit: 4, period: @period) do |req| if req.path == '/login' && req.post? req.params['email'] end end end it 'should not differentiate requests when throttle_discriminator_normalizer is enabled' do within_same_period do post_logins key = "rack::attack:#{Time.now.to_i / @period}:logins/email:person@example.com" _(Rack::Attack.cache.store.read(key)).must_equal 3 end end it 'should differentiate requests when throttle_discriminator_normalizer is disabled' do begin prev = Rack::Attack.throttle_discriminator_normalizer Rack::Attack.throttle_discriminator_normalizer = nil within_same_period do post_logins @emails.each do |email| key = "rack::attack:#{Time.now.to_i / @period}:logins/email:#{email}" _(Rack::Attack.cache.store.read(key)).must_equal 1 end end ensure Rack::Attack.throttle_discriminator_normalizer = prev end end def post_logins @emails.each do |email| post '/login', email: email end end end rack-attack-6.8.0/spec/rack_attack_track_spec.rb0000644000004100000410000000255115076136130021673 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.track' do let(:notifications) { [] } before do Rack::Attack.track("everything") { |_req| true } end it_allows_ok_requests it "should tag the env" do get '/' _(last_request.env['rack.attack.matched']).must_equal 'everything' _(last_request.env['rack.attack.match_type']).must_equal :track end describe "with a notification subscriber and two tracks" do before do # A second track Rack::Attack.track("homepage") { |req| req.path == "/" } ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/" end it "should notify twice" do _(notifications.size).must_equal 2 end end describe "without limit and period options" do it "should assign the track filter to a Check instance" do track = Rack::Attack.track("homepage") { |req| req.path == "/" } _(track.filter.class).must_equal Rack::Attack::Check end end describe "with limit and period options" do it "should assign the track filter to a Throttle instance" do track = Rack::Attack.track("homepage", limit: 10, period: 10) { |req| req.path == "/" } _(track.filter.class).must_equal Rack::Attack::Throttle end end end rack-attack-6.8.0/spec/rack_attack_instrumentation_spec.rb0000644000004100000410000000204715076136130024032 0ustar www-datawww-data# frozen_string_literal: true require_relative "spec_helper" require 'active_support' require 'active_support/subscriber' class CustomSubscriber < ActiveSupport::Subscriber @notification_count = 0 class << self attr_accessor :notification_count end def throttle(_event) self.class.notification_count += 1 end end describe 'Rack::Attack.instrument' do before do @period = 60 # Use a long period; failures due to cache key rotation less likely Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |req| req.ip } end describe "with throttling" do before do ActiveSupport::Notifications.stub(:notifier, ActiveSupport::Notifications::Fanout.new) do CustomSubscriber.attach_to("rack_attack") 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } end end it 'should instrument without error' do _(last_response.status).must_equal 429 assert_equal 1, CustomSubscriber.notification_count end end end rack-attack-6.8.0/spec/rack_attack_reset_spec.rb0000644000004100000410000000615115076136130021711 0ustar www-datawww-data# frozen_string_literal: true require_relative "spec_helper" describe "Rack::Attack.reset!" do it "raises an error when is not supported by cache store" do Rack::Attack.cache.store = Class.new assert_raises(Rack::Attack::IncompatibleStoreError) do Rack::Attack.reset! end end if defined?(Redis) it "should delete rack attack keys" do redis = Redis.new redis.set("key", "value") redis.set("#{Rack::Attack.cache.prefix}::key", "value") Rack::Attack.cache.store = redis Rack::Attack.reset! _(redis.get("key")).must_equal "value" _(redis.get("#{Rack::Attack.cache.prefix}::key")).must_be_nil end end if defined?(Redis::Store) it "should delete rack attack keys" do redis_store = Redis::Store.new redis_store.set("key", "value") redis_store.set("#{Rack::Attack.cache.prefix}::key", "value") Rack::Attack.cache.store = redis_store Rack::Attack.reset! _(redis_store.get("key")).must_equal "value" _(redis_store.get("#{Rack::Attack.cache.prefix}::key")).must_be_nil end end if defined?(Redis) && defined?(ActiveSupport::Cache::RedisCacheStore) it "should delete rack attack keys" do redis_cache_store = ActiveSupport::Cache::RedisCacheStore.new redis_cache_store.write("key", "value") redis_cache_store.write("#{Rack::Attack.cache.prefix}::key", "value") Rack::Attack.cache.store = redis_cache_store Rack::Attack.reset! _(redis_cache_store.read("key")).must_equal "value" _(redis_cache_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil end describe "with a namespaced cache" do it "should delete rack attack keys" do redis_cache_store = ActiveSupport::Cache::RedisCacheStore.new(namespace: "ns") redis_cache_store.write("key", "value") redis_cache_store.write("#{Rack::Attack.cache.prefix}::key", "value") Rack::Attack.cache.store = redis_cache_store Rack::Attack.reset! _(redis_cache_store.read("key")).must_equal "value" _(redis_cache_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil end end end if defined?(ActiveSupport::Cache::MemoryStore) it "should delete rack attack keys" do memory_store = ActiveSupport::Cache::MemoryStore.new memory_store.write("key", "value") memory_store.write("#{Rack::Attack.cache.prefix}::key", "value") Rack::Attack.cache.store = memory_store Rack::Attack.reset! _(memory_store.read("key")).must_equal "value" _(memory_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil end describe "with a namespaced cache" do it "should delete rack attack keys" do memory_store = ActiveSupport::Cache::MemoryStore.new(namespace: "ns") memory_store.write("key", "value") memory_store.write("#{Rack::Attack.cache.prefix}::key", "value") Rack::Attack.cache.store = memory_store Rack::Attack.reset! _(memory_store.read("key")).must_equal "value" _(memory_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil end end end end rack-attack-6.8.0/spec/rack_attack_path_normalizer_spec.rb0000644000004100000410000000072315076136130023764 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe Rack::Attack::PathNormalizer do subject { Rack::Attack::PathNormalizer } it 'should have a normalize_path method' do _(subject.normalize_path('/foo')).must_equal '/foo' end describe 'FallbackNormalizer' do subject { Rack::Attack::FallbackPathNormalizer } it '#normalize_path does not change the path' do _(subject.normalize_path('')).must_equal '' end end end rack-attack-6.8.0/spec/support/0000755000004100000410000000000015076136130016412 5ustar www-datawww-datarack-attack-6.8.0/spec/support/cache_store_helper.rb0000644000004100000410000000432015076136130022554 0ustar www-datawww-data# frozen_string_literal: true require_relative 'freeze_time_helper' class Minitest::Spec def self.it_works_for_cache_backed_features(options) fetch_from_store = options.fetch(:fetch_from_store) it "works for throttle" do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end within_same_period do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end end it "works for fail2ban" do Rack::Attack.blocklist("fail2ban pentesters") do |request| Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("private-place") end end within_same_period do get "/" assert_equal 200, last_response.status get "/private-place" assert_equal 403, last_response.status get "/private-place" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end end it "works for allow2ban" do Rack::Attack.blocklist("allow2ban pentesters") do |request| Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("scarce-resource") end end within_same_period do get "/" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end end it "doesn't leak keys" do Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| request.ip end key = nil within_same_period do key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" get "/", {}, "REMOTE_ADDR" => "1.2.3.4" end assert fetch_from_store.call(key) sleep 2.1 assert_nil fetch_from_store.call(key) end end end rack-attack-6.8.0/spec/support/freeze_time_helper.rb0000644000004100000410000000021515076136130022572 0ustar www-datawww-data# frozen_string_literal: true require "timecop" class Minitest::Spec def within_same_period(&block) Timecop.freeze(&block) end end rack-attack-6.8.0/spec/acceptance/0000755000004100000410000000000015076136130016764 5ustar www-datawww-datarack-attack-6.8.0/spec/acceptance/safelisting_spec.rb0000644000004100000410000000575715076136130022651 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "#safelist" do let(:notifications) { [] } before do Rack::Attack.blocklist do |request| request.ip == "1.2.3.4" end Rack::Attack.safelist do |request| request.path == "/safe_space" end end it "forbids request if blocklist condition is true and safelist is false" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false and safelist is false" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if blocklist condition is false and safelist is true" do get "/safe_space", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if both blocklist and safelist conditions are true" do get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end it "notifies when the request is safe" do ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_nil notification[:request].env["rack.attack.matched"] assert_equal :safelist, notification[:request].env["rack.attack.match_type"] end end describe "#safelist with name" do let(:notifications) { [] } before do Rack::Attack.blocklist("block 1.2.3.4") do |request| request.ip == "1.2.3.4" end Rack::Attack.safelist("safe path") do |request| request.path == "/safe_space" end end it "forbids request if blocklist condition is true and safelist is false" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false and safelist is false" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if blocklist condition is false and safelist is true" do get "/safe_space", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if both blocklist and safelist conditions are true" do get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end it "notifies when the request is safe" do ActiveSupport::Notifications.subscribe("safelist.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal "safe path", notification[:request].env["rack.attack.matched"] assert_equal :safelist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/customizing_blocked_response_spec.rb0000644000004100000410000000253415076136130026303 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Customizing block responses" do before do Rack::Attack.blocklist("block 1.2.3.4") do |request| request.ip == "1.2.3.4" end end it "can be customized" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status Rack::Attack.blocklisted_responder = lambda do |_req| [503, {}, ["Blocked"]] end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 503, last_response.status assert_equal "Blocked", last_response.body end it "exposes match data" do matched = nil match_type = nil Rack::Attack.blocklisted_responder = lambda do |req| matched = req.env['rack.attack.matched'] match_type = req.env['rack.attack.match_type'] [503, {}, ["Blocked"]] end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal "block 1.2.3.4", matched assert_equal :blocklist, match_type end it "supports old style" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status silence_warnings do Rack::Attack.blocklisted_response = lambda do |_env| [503, {}, ["Blocked"]] end end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 503, last_response.status assert_equal "Blocked", last_response.body end end rack-attack-6.8.0/spec/acceptance/blocking_ip_spec.rb0000644000004100000410000000205515076136130022605 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Blocking an IP" do let(:notifications) { [] } before do Rack::Attack.blocklist_ip("1.2.3.4") end it "forbids request if IP matches" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if IP doesn't match" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds if IP is missing" do get "/", {}, "REMOTE_ADDR" => "" assert_equal 200, last_response.status end it "notifies when the request is blocked" do ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 1, notifications.size notification = notifications.pop assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/rails_middleware_spec.rb0000644000004100000410000000104115076136130023626 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" if defined?(Rails::Application) describe "Middleware for Rails" do before do @app = Class.new(Rails::Application) do config.eager_load = false config.logger = Logger.new(nil) # avoid creating the log/ directory automatically config.cache_store = :null_store # avoid creating tmp/ directory for cache end end it "is used by default" do @app.initialize! assert @app.middleware.include?(Rack::Attack) end end end rack-attack-6.8.0/spec/acceptance/stores/0000755000004100000410000000000015076136130020303 5ustar www-datawww-datarack-attack-6.8.0/spec/acceptance/stores/active_support_memory_store_spec.rb0000644000004100000410000000066515076136130027524 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" require_relative "../../support/cache_store_helper" describe "ActiveSupport::Cache::MemoryStore as a cache backend" do before do Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end rack-attack-6.8.0/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb0000644000004100000410000000166515076136130032010 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" should_run = defined?(::ConnectionPool) && defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore) if should_run require_relative "../../support/cache_store_helper" describe "ActiveSupport::Cache::RedisCacheStore (pooled) as a cache backend" do before do Rack::Attack.cache.store = if ActiveSupport.gem_version >= Gem::Version.new("7.2.0") ActiveSupport::Cache::RedisCacheStore.new(pool: true) else ActiveSupport::Cache::RedisCacheStore.new(pool_size: 2) end end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.8.0/spec/acceptance/stores/dalli_client_spec.rb0000644000004100000410000000072115076136130024265 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::Dalli) require_relative "../../support/cache_store_helper" require "dalli" describe "Dalli::Client as a cache backend" do before do Rack::Attack.cache.store = Dalli::Client.new end after do Rack::Attack.cache.store.flush_all end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end end rack-attack-6.8.0/spec/acceptance/stores/connection_pool_dalli_client_spec.rb0000644000004100000410000000116415076136130027537 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::Dalli) && defined?(::ConnectionPool) require_relative "../../support/cache_store_helper" require "connection_pool" require "dalli" describe "ConnectionPool with Dalli::Client as a cache backend" do before do Rack::Attack.cache.store = ConnectionPool.new { Dalli::Client.new } end after do Rack::Attack.cache.store.with { |client| client.flush_all } end it_works_for_cache_backed_features( fetch_from_store: ->(key) { Rack::Attack.cache.store.with { |client| client.fetch(key) } } ) end end rack-attack-6.8.0/spec/acceptance/stores/redis_spec.rb0000644000004100000410000000066115076136130022753 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::Redis) require_relative "../../support/cache_store_helper" describe "Plain redis as a cache backend" do before do Rack::Attack.cache.store = Redis.new end after do Rack::Attack.cache.store.flushdb end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.get(key) }) end end rack-attack-6.8.0/spec/acceptance/stores/redis_store_spec.rb0000644000004100000410000000070015076136130024161 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" require_relative "../../support/cache_store_helper" if defined?(::Redis::Store) describe "Redis::Store as a cache backend" do before do Rack::Attack.cache.store = ::Redis::Store.new end after do Rack::Attack.cache.store.flushdb end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.8.0/spec/acceptance/stores/active_support_redis_cache_store_spec.rb0000644000004100000410000000117515076136130030442 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" should_run = defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore) if should_run require_relative "../../support/cache_store_helper" describe "ActiveSupport::Cache::RedisCacheStore as a cache backend" do before do Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.8.0/spec/acceptance/stores/active_support_mem_cache_store_spec.rb0000644000004100000410000000074615076136130030115 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::Dalli) require_relative "../../support/cache_store_helper" describe "ActiveSupport::Cache::MemCacheStore as a cache backend" do before do Rack::Attack.cache.store = ActiveSupport::Cache::MemCacheStore.new end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.8.0/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb0000644000004100000410000000143215076136130031450 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::ConnectionPool) && defined?(::Dalli) require_relative "../../support/cache_store_helper" describe "ActiveSupport::Cache::MemCacheStore (pooled) as a cache backend" do before do Rack::Attack.cache.store = if ActiveSupport.gem_version >= Gem::Version.new("7.2.0") ActiveSupport::Cache::MemCacheStore.new(pool: true) else ActiveSupport::Cache::MemCacheStore.new(pool_size: 2) end end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.8.0/spec/acceptance/safelisting_ip_spec.rb0000644000004100000410000000311615076136130023324 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Safelist an IP" do let(:notifications) { [] } before do Rack::Attack.blocklist("admin") do |request| request.path == "/admin" end Rack::Attack.safelist_ip("5.6.7.8") end it "forbids request if blocklist condition is true and safelist is false" do get "/admin", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "forbids request if blocklist condition is true and safelist is false (missing IP)" do get "/admin", {}, "REMOTE_ADDR" => "" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false and safelist is false" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end it "succeeds request if blocklist condition is false and safelist is true" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if both blocklist and safelist conditions are true" do get "/admin", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "notifies when the request is safe" do ActiveSupport::Notifications.subscribe("safelist.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/admin", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal :safelist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/extending_request_object_spec.rb0000644000004100000410000000133615076136130025411 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Extending the request object" do before do Rack::Attack::Request.define_method :authorized? do env["APIKey"] == "private-secret" end Rack::Attack.blocklist("unauthorized requests") do |request| !request.authorized? end end # We don't want the extension to leak to other test cases after do Rack::Attack::Request.undef_method :authorized? end it "forbids request if blocklist condition is true" do get "/" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false" do get "/", {}, "APIKey" => "private-secret" assert_equal 200, last_response.status end end rack-attack-6.8.0/spec/acceptance/customizing_throttled_response_spec.rb0000644000004100000410000000405015076136130026704 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Customizing throttled response" do before do Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end end it "can be customized" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status Rack::Attack.throttled_responder = lambda do |_req| [503, {}, ["Throttled"]] end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 503, last_response.status assert_equal "Throttled", last_response.body end it "exposes match data" do matched = nil match_type = nil match_data = nil match_discriminator = nil Rack::Attack.throttled_responder = lambda do |req| matched = req.env['rack.attack.matched'] match_type = req.env['rack.attack.match_type'] match_data = req.env['rack.attack.match_data'] match_discriminator = req.env['rack.attack.match_discriminator'] [429, {}, ["Throttled"]] end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal "by ip", matched assert_equal :throttle, match_type assert_equal 60, match_data[:period] assert_equal 1, match_data[:limit] assert_equal 2, match_data[:count] assert_equal "1.2.3.4", match_discriminator get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 3, match_data[:count] end it "supports old style" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status silence_warnings do Rack::Attack.throttled_response = lambda do |_req| [503, {}, ["Throttled"]] end end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 503, last_response.status assert_equal "Throttled", last_response.body end end rack-attack-6.8.0/spec/acceptance/blocking_subnet_spec.rb0000644000004100000410000000214615076136130023476 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Blocking an IP subnet" do let(:notifications) { [] } before do Rack::Attack.blocklist_ip("1.2.3.4/31") end it "forbids request if IP is inside the subnet" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "forbids request for another IP in the subnet" do get "/", {}, "REMOTE_ADDR" => "1.2.3.5" assert_equal 403, last_response.status end it "succeeds if IP is outside the subnet" do get "/", {}, "REMOTE_ADDR" => "1.2.3.6" assert_equal 200, last_response.status end it "notifies when the request is blocked" do ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 1, notifications.size notification = notifications.pop assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/blocking_spec.rb0000644000004100000410000000410315076136130022111 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "#blocklist" do let(:notifications) { [] } before do Rack::Attack.blocklist do |request| request.ip == "1.2.3.4" end end it "forbids request if blocklist condition is true" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "notifies when the request is blocked" do ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 1, notifications.size notification = notifications.pop assert_nil notification[:request].env["rack.attack.matched"] assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] end end describe "#blocklist with name" do let(:notifications) { [] } before do Rack::Attack.blocklist("block 1.2.3.4") do |request| request.ip == "1.2.3.4" end end it "forbids request if blocklist condition is true" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "notifies when the request is blocked" do ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 1, notifications.size notification = notifications.pop assert_equal "block 1.2.3.4", notification[:request].env["rack.attack.matched"] assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/throttling_spec.rb0000644000004100000410000001153315076136130022524 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "timecop" describe "#throttle" do let(:notifications) { [] } before do Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new end it "allows one request per minute by IP" do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status assert_nil last_response.headers["Retry-After"] assert_equal "Retry later\n", last_response.body get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status Timecop.travel(60) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end end it "returns correct Retry-After header if enabled" do Rack::Attack.throttled_response_retry_after_header = true Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end Timecop.freeze(Time.at(0)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end Timecop.freeze(Time.at(25)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal "35", last_response.headers["Retry-After"] end end it "supports limit to be dynamic" do # Could be used to have different rate limits for authorized # vs general requests limit_proc = lambda do |request| if request.env["X-APIKey"] == "private-secret" 2 else 1 end end Rack::Attack.throttle("by ip", limit: limit_proc, period: 60) do |request| request.ip end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 429, last_response.status end it "supports period to be dynamic" do # Could be used to have different rate limits for authorized # vs general requests period_proc = lambda do |request| if request.env["X-APIKey"] == "private-secret" 10 else 30 end end Rack::Attack.throttle("by ip", limit: 1, period: period_proc) do |request| request.ip end # Using Time#at to align to start/end of periods exactly # to achieve consistenty in different test runs Timecop.travel(Time.at(0)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end Timecop.travel(Time.at(10)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end Timecop.travel(Time.at(30)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end Timecop.travel(Time.at(0)) do get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 429, last_response.status end Timecop.travel(Time.at(10)) do get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status end end it "notifies when the request is throttled" do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal "by ip", notification[:request].env["rack.attack.matched"] assert_equal :throttle, notification[:request].env["rack.attack.match_type"] assert_equal 60, notification[:request].env["rack.attack.match_data"][:period] assert_equal 1, notification[:request].env["rack.attack.match_data"][:limit] assert_equal 2, notification[:request].env["rack.attack.match_data"][:count] assert_equal "1.2.3.4", notification[:request].env["rack.attack.match_discriminator"] end end rack-attack-6.8.0/spec/acceptance/safelisting_subnet_spec.rb0000644000004100000410000000264615076136130024223 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Safelisting an IP subnet" do let(:notifications) { [] } before do Rack::Attack.blocklist("admin") do |request| request.path == "/admin" end Rack::Attack.safelist_ip("5.6.0.0/16") end it "forbids request if blocklist condition is true and safelist is false" do get "/admin", {}, "REMOTE_ADDR" => "5.7.0.0" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false and safelist is false" do get "/", {}, "REMOTE_ADDR" => "5.7.0.0" assert_equal 200, last_response.status end it "succeeds request if blocklist condition is false and safelist is true" do get "/", {}, "REMOTE_ADDR" => "5.6.0.0" assert_equal 200, last_response.status end it "succeeds request if both blocklist and safelist conditions are true" do get "/admin", {}, "REMOTE_ADDR" => "5.6.255.255" assert_equal 200, last_response.status end it "notifies when the request is safe" do ActiveSupport::Notifications.subscribe("safelist.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/admin", {}, "REMOTE_ADDR" => "5.6.0.0" assert_equal 200, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal :safelist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/cache_store_config_for_allow2ban_spec.rb0000644000004100000410000000616615076136130026747 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "minitest/stub_const" describe "Cache store config when using allow2ban" do before do Rack::Attack.blocklist("allow2ban pentesters") do |request| Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("scarce-resource") end end end unless defined?(Rails) it "gives semantic error if no store was configured" do assert_raises(Rack::Attack::MissingStoreError) do get "/scarce-resource" end end end it "gives semantic error if store is missing #read method" do raised_exception = nil fake_store_class = Class.new do def write(key, value); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/scarce-resource" end end assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message end it "gives semantic error if store is missing #write method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/scarce-resource" end end assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message end it "gives semantic error if store is missing #increment method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def write(key, value); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/scarce-resource" end end assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message end it "works with any object that responds to #read, #write and #increment" do fake_store_class = Class.new do attr_accessor :backend def initialize @backend = {} end def read(key) @backend[key] end def write(key, value, _options = {}) @backend[key] = value end def increment(key, _count, _options = {}) @backend[key] ||= 0 @backend[key] += 1 end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new get "/" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end end end rack-attack-6.8.0/spec/acceptance/cache_store_config_for_fail2ban_spec.rb0000644000004100000410000000572015076136130026537 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "minitest/stub_const" describe "Cache store config when using fail2ban" do before do Rack::Attack.blocklist("fail2ban pentesters") do |request| Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("private-place") end end end unless defined?(Rails) it "gives semantic error if no store was configured" do assert_raises(Rack::Attack::MissingStoreError) do get "/private-place" end end end it "gives semantic error if store is missing #read method" do raised_exception = nil fake_store_class = Class.new do def write(key, value); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/private-place" end end assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message end it "gives semantic error if store is missing #write method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/private-place" end end assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message end it "gives semantic error if store is missing #increment method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def write(key, value); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/private-place" end end assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message end it "works with any object that responds to #read, #write and #increment" do fake_store_class = Class.new do attr_accessor :backend def initialize @backend = {} end def read(key) @backend[key] end def write(key, value, _options = {}) @backend[key] = value end def increment(key, _count, _options = {}) @backend[key] ||= 0 @backend[key] += 1 end end Rack::Attack.cache.store = fake_store_class.new get "/" assert_equal 200, last_response.status get "/private-place" assert_equal 403, last_response.status get "/private-place" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end end rack-attack-6.8.0/spec/acceptance/track_throttle_spec.rb0000644000004100000410000000242215076136130023354 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "timecop" describe "#track with throttle-ish options" do let(:notifications) { [] } it "notifies when throttle goes over the limit without actually throttling requests" do Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.track("by ip", limit: 1, period: 60) do |request| request.ip end ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert notifications.empty? assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert notifications.empty? assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 1, notifications.size notification = notifications.pop assert_equal "by ip", notification[:request].env["rack.attack.matched"] assert_equal :track, notification[:request].env["rack.attack.match_type"] assert_equal 200, last_response.status Timecop.travel(60) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert notifications.empty? assert_equal 200, last_response.status end end end rack-attack-6.8.0/spec/acceptance/cache_store_config_with_rails_spec.rb0000644000004100000410000000157115076136130026360 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "minitest/stub_const" require "ostruct" describe "Cache store config with Rails" do before do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end end unless defined?(Rails) it "fails when Rails.cache is not set" do Object.stub_const(:Rails, OpenStruct.new(cache: nil)) do assert_raises(Rack::Attack::MissingStoreError) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" end end end end it "works when Rails.cache is set" do Object.stub_const(:Rails, OpenStruct.new(cache: ActiveSupport::Cache::MemoryStore.new)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end end end rack-attack-6.8.0/spec/acceptance/cache_store_config_for_throttle_spec.rb0000644000004100000410000000231015076136130026716 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "Cache store config when throttling without Rails" do before do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end end unless defined?(Rails) it "gives semantic error if no store was configured" do assert_raises(Rack::Attack::MissingStoreError) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" end end end it "gives semantic error if incompatible store was configured" do Rack::Attack.cache.store = Object.new assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" end end it "works with any object that responds to #increment" do basic_store_class = Class.new do attr_accessor :counts def initialize @counts = {} end def increment(key, _count, _options) @counts[key] ||= 0 @counts[key] += 1 end end Rack::Attack.cache.store = basic_store_class.new get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end end rack-attack-6.8.0/spec/acceptance/allow2ban_spec.rb0000644000004100000410000000336315076136130022211 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "timecop" describe "allow2ban" do before do Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.blocklist("allow2ban pentesters") do |request| Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("scarce-resource") end end end it "returns OK for many requests that doesn't match the filter" do get "/" assert_equal 200, last_response.status get "/" assert_equal 200, last_response.status end it "returns OK for first request that matches the filter" do get "/scarce-resource" assert_equal 200, last_response.status end it "forbids all access after reaching maxretry limit" do get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end it "restores access after bantime elapsed" do get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/" assert_equal 403, last_response.status Timecop.travel(60) do get "/" assert_equal 200, last_response.status end end it "does not forbid all access if maxrety condition is met but not within the findtime timespan" do get "/scarce-resource" assert_equal 200, last_response.status Timecop.travel(31) do get "/scarce-resource" assert_equal 200, last_response.status get "/" assert_equal 200, last_response.status end end end rack-attack-6.8.0/spec/acceptance/track_spec.rb0000644000004100000410000000140415076136130021426 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "#track" do let(:notifications) { [] } it "notifies when track block returns true" do Rack::Attack.track("ip 1.2.3.4") do |request| request.ip == "1.2.3.4" end ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert notifications.empty? get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 1, notifications.size notification = notifications.pop assert_equal "ip 1.2.3.4", notification[:request].env["rack.attack.matched"] assert_equal :track, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/acceptance/fail2ban_spec.rb0000644000004100000410000000621315076136130022003 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "timecop" describe "fail2ban" do let(:notifications) { [] } before do Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.blocklist("fail2ban pentesters") do |request| Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("private-place") end end end it "returns OK for many requests to non filtered path" do get "/" assert_equal 200, last_response.status get "/" assert_equal 200, last_response.status end it "forbids access to private path" do get "/private-place" assert_equal 403, last_response.status end it "returns OK for non filtered path if yet not reached maxretry limit" do get "/private-place" assert_equal 403, last_response.status get "/" assert_equal 200, last_response.status end it "forbids all access after reaching maxretry limit" do get "/private-place" assert_equal 403, last_response.status get "/private-place" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end it "restores access after bantime elapsed" do get "/private-place" assert_equal 403, last_response.status get "/private-place" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status Timecop.travel(60) do get "/" assert_equal 200, last_response.status end end it "does not forbid all access if maxrety condition is met but not within the findtime timespan" do get "/private-place" assert_equal 403, last_response.status Timecop.travel(31) do get "/private-place" assert_equal 403, last_response.status get "/" assert_equal 200, last_response.status end end it "notifies when the request is blocked" do ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| notifications.push(payload) end get "/" assert_equal 200, last_response.status assert notifications.empty? get "/private-place" assert_equal 403, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal 'fail2ban pentesters', notification[:request].env["rack.attack.matched"] assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] get "/" assert_equal 200, last_response.status assert notifications.empty? get "/private-place" assert_equal 403, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal 'fail2ban pentesters', notification[:request].env["rack.attack.matched"] assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] get "/" assert_equal 403, last_response.status assert_equal 1, notifications.size notification = notifications.pop assert_equal 'fail2ban pentesters', notification[:request].env["rack.attack.matched"] assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] end end rack-attack-6.8.0/spec/rack_attack_dalli_proxy_spec.rb0000644000004100000410000000044015076136130023110 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe Rack::Attack::StoreProxy::DalliProxy do it 'should stub Dalli::Client#with on older clients' do proxy = Rack::Attack::StoreProxy::DalliProxy.new(Class.new) proxy.with {} # will not raise an error end end rack-attack-6.8.0/spec/allow2ban_spec.rb0000644000004100000410000000674015076136130020125 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.Allow2Ban' do before do # Use a long findtime; failures due to cache key rotation less likely @cache = Rack::Attack.cache @findtime = 60 @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @f2b_options = { bantime: @bantime, findtime: @findtime, maxretry: 2 } Rack::Attack.blocklist('pentest') do |req| Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ } end end describe 'discriminator has not been banned' do describe 'making ok request' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' _(last_response.status).must_equal 200 end end describe 'making qualifying request' do describe 'when not at maxretry' do before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'succeeds' do _(last_response.status).must_equal 200 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end it 'is not banned' do key = "rack::attack:allow2ban:1.2.3.4" _(@cache.store.read(key)).must_be_nil end end describe 'when at maxretry' do before do # maxretry is 2 - so hit with an extra failed request first get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'succeeds' do _(last_response.status).must_equal 200 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end end end describe 'discriminator has been banned' do before do # maxretry is 2 - so hit enough times to get banned get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end describe 'making request for other discriminator' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '2.2.3.4' _(last_response.status).must_equal 200 end end describe 'making ok request' do before do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end describe 'making failing request' do before do get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end end end rack-attack-6.8.0/spec/fail2ban_spec.rb0000644000004100000410000001010715076136130017712 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.Fail2Ban' do before do # Use a long findtime; failures due to cache key rotation less likely @cache = Rack::Attack.cache @findtime = 60 @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @f2b_options = { bantime: @bantime, findtime: @findtime, maxretry: 2 } Rack::Attack.blocklist('pentest') do |req| Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ } end end describe 'discriminator has not been banned' do describe 'making ok request' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' _(last_response.status).must_equal 200 end end describe 'making failing request' do describe 'when not at maxretry' do before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'fails' do _(last_response.status).must_equal 403 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end it 'is not banned' do key = "rack::attack:fail2ban:1.2.3.4" _(@cache.store.read(key)).must_be_nil end end describe 'when at maxretry' do before do # maxretry is 2 - so hit with an extra failed request first get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end describe 'reset after success' do before do get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' Rack::Attack::Fail2Ban.reset('1.2.3.4', @f2b_options) get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'succeeds' do _(last_response.status).must_equal 200 end it 'resets fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" assert_nil @cache.store.read(key) end it 'IP is not banned' do _(Rack::Attack::Fail2Ban.banned?('1.2.3.4')).must_equal false end end end end describe 'discriminator has been banned' do before do # maxretry is 2 - so hit enough times to get banned get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end describe 'making request for other discriminator' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '2.2.3.4' _(last_response.status).must_equal 200 end end describe 'making ok request' do before do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end describe 'making failing request' do before do get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end end end rack-attack-6.8.0/spec/configuration_spec.rb0000644000004100000410000000143215076136130021104 0ustar www-datawww-data# frozen_string_literal: true require_relative "spec_helper" describe Rack::Attack::Configuration do subject { Rack::Attack::Configuration.new } describe 'attributes' do it 'exposes the safelists attribute' do _(subject.safelists).must_equal({}) end it 'exposes the blocklists attribute' do _(subject.blocklists).must_equal({}) end it 'exposes the throttles attribute' do _(subject.throttles).must_equal({}) end it 'exposes the tracks attribute' do _(subject.tracks).must_equal({}) end it 'exposes the anonymous_blocklists attribute' do _(subject.anonymous_blocklists).must_equal([]) end it 'exposes the anonymous_safelists attribute' do _(subject.anonymous_safelists).must_equal([]) end end end rack-attack-6.8.0/Rakefile0000644000004100000410000000107215076136130015411 0ustar www-datawww-data# frozen_string_literal: true require "rubygems" require "bundler/setup" require 'bundler/gem_tasks' require 'rake/testtask' require "rubocop/rake_task" RuboCop::RakeTask.new namespace :test do Rake::TestTask.new(:units) do |t| t.pattern = "spec/*_spec.rb" end Rake::TestTask.new(:integration) do |t| t.pattern = "spec/integration/*_spec.rb" end Rake::TestTask.new(:acceptance) do |t| t.pattern = "spec/acceptance/**/*_spec.rb" end end Rake::TestTask.new(:test) do |t| t.pattern = "spec/**/*_spec.rb" end task default: [:rubocop, :test] rack-attack-6.8.0/LICENSE0000644000004100000410000000206515076136130014754 0ustar www-datawww-dataThe MIT License Copyright (c) 2016 Kickstarter, PBC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. rack-attack-6.8.0/README.md0000644000004100000410000004443015076136130015230 0ustar www-datawww-data:warning: You are viewing the development's branch version of README which might contain documentation for unreleased features. For the README consistent with the latest released version see https://github.com/rack/rack-attack/blob/6-stable/README.md. # Rack::Attack *Rack middleware for blocking & throttling abusive requests* Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily decide when to *allow*, *block* and *throttle* based on properties of the request. See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack. [![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](https://badge.fury.io/rb/rack-attack) [![build](https://github.com/rack/rack-attack/actions/workflows/build.yml/badge.svg)](https://github.com/rack/rack-attack/actions/workflows/build.yml) [![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) ## Table of contents - [Getting started](#getting-started) - [Installing](#installing) - [Plugging into the application](#plugging-into-the-application) - [Usage](#usage) - [Safelisting](#safelisting) - [`safelist_ip(ip_address_string)`](#safelist_ipip_address_string) - [`safelist_ip(ip_subnet_string)`](#safelist_ipip_subnet_string) - [`safelist(name, &block)`](#safelistname-block) - [Blocking](#blocking) - [`blocklist_ip(ip_address_string)`](#blocklist_ipip_address_string) - [`blocklist_ip(ip_subnet_string)`](#blocklist_ipip_subnet_string) - [`blocklist(name, &block)`](#blocklistname-block) - [Fail2Ban](#fail2ban) - [Allow2Ban](#allow2ban) - [Throttling](#throttling) - [`throttle(name, options, &block)`](#throttlename-options-block) - [Tracks](#tracks) - [Cache store configuration](#cache-store-configuration) - [Customizing responses](#customizing-responses) - [RateLimit headers for well-behaved clients](#ratelimit-headers-for-well-behaved-clients) - [Logging & Instrumentation](#logging--instrumentation) - [Testing](#testing) - [How it works](#how-it-works) - [About Tracks](#about-tracks) - [Performance](#performance) - [Motivation](#motivation) - [Contributing](#contributing) - [Code of Conduct](#code-of-conduct) - [Development setup](#development-setup) - [License](#license) ## Getting started ### Installing Add this line to your application's Gemfile: ```ruby # In your Gemfile gem "rack-attack", "~> 6.8" ``` And then execute: $ bundle Or install it yourself as: $ gem install rack-attack ### Plugging into the application Then tell your ruby web application to use rack-attack as a middleware. a) For __rails__ applications it is used by default. You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing: ```ruby Rack::Attack.enabled = false ``` b) For __rack__ applications: ```ruby # In config.ru require "rack/attack" use Rack::Attack ``` __IMPORTANT__: By default, rack-attack won't perform any blocking or throttling, until you specifically tell it what to protect against by configuring some rules. ## Usage *Tip:* If you just want to get going asap, then you can take our [example configuration](docs/example_configuration.md) and tailor it to your needs, or check out the [advanced configuration](docs/advanced_configuration.md) examples. Define rules by calling `Rack::Attack` public methods, in any file that runs when your application is being initialized. For rails applications this means creating a new file named `config/initializers/rack_attack.rb` and writing your rules there. ### Safelisting Safelists have the most precedence, so any request matching a safelist would be allowed despite matching any number of blocklists or throttles. #### `safelist_ip(ip_address_string)` E.g. ```ruby # config/initializers/rack_attack.rb (for rails app) Rack::Attack.safelist_ip("5.6.7.8") ``` #### `safelist_ip(ip_subnet_string)` E.g. ```ruby # config/initializers/rack_attack.rb (for rails app) Rack::Attack.safelist_ip("5.6.7.0/24") ``` #### `safelist(name, &block)` Name your custom safelist and make your ruby-block argument return a truthy value if you want the request to be allowed, and falsy otherwise. The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). E.g. ```ruby # config/initializers/rack_attack.rb (for rails apps) # Provided that trusted users use an HTTP request header named APIKey Rack::Attack.safelist("mark any authenticated access safe") do |request| # Requests are allowed if the return value is truthy request.env["HTTP_APIKEY"] == "secret-string" end # Always allow requests from localhost # (blocklist & throttles are skipped) Rack::Attack.safelist('allow from localhost') do |req| # Requests are allowed if the return value is truthy '127.0.0.1' == req.ip || '::1' == req.ip end ``` ### Blocking #### `blocklist_ip(ip_address_string)` E.g. ```ruby # config/initializers/rack_attack.rb (for rails apps) Rack::Attack.blocklist_ip("1.2.3.4") ``` #### `blocklist_ip(ip_subnet_string)` E.g. ```ruby # config/initializers/rack_attack.rb (for rails apps) Rack::Attack.blocklist_ip("1.2.0.0/16") ``` #### `blocklist(name, &block)` Name your custom blocklist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise. The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). E.g. ```ruby # config/initializers/rack_attack.rb (for rails apps) Rack::Attack.blocklist("block all access to admin") do |request| # Requests are blocked if the return value is truthy request.path.start_with?("/admin") end Rack::Attack.blocklist('block bad UA logins') do |req| req.path == '/login' && req.post? && req.user_agent == 'BadUA' end ``` #### Fail2Ban `Fail2Ban.filter` can be used within a blocklist to block all requests from misbehaving clients. This pattern is inspired by [fail2ban](https://www.fail2ban.org/wiki/index.php/Main_Page). See the [fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jail_Options) for more details on how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter. Fail2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present). ```ruby # Block suspicious requests for '/etc/password' or wordpress specific paths. # After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes. Rack::Attack.blocklist('fail2ban pentesters') do |req| # `filter` returns truthy value if request fails, or if it's from a previously banned IP # so the request is blocked Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 5.minutes) do # The count for the IP is incremented if the return value is truthy CGI.unescape(req.query_string) =~ %r{/etc/passwd} || req.path.include?('/etc/passwd') || req.path.include?('wp-admin') || req.path.include?('wp-login') end end ``` Note that `Fail2Ban` filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`. #### Allow2Ban `Allow2Ban.filter` works the same way as the `Fail2Ban.filter` except that it *allows* requests from misbehaving clients until such time as they reach maxretry at which they are cut off as per normal. Allow2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present). ```ruby # Lockout IP addresses that are hammering your login page. # After 20 requests in 1 minute, block all requests from that IP for 1 hour. Rack::Attack.blocklist('allow2ban login scrapers') do |req| # `filter` returns false value if request is to your login page (but still # increments the count) so request below the limit are not blocked until # they hit the limit. At that point, filter will return true and block. Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do # The count for the IP is incremented if the return value is truthy. req.path == '/login' and req.post? end end ``` ### Throttling Throttle state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present). #### `throttle(name, options, &block)` Name your custom throttle, provide `limit` and `period` as options, and make your ruby-block argument return the __discriminator__. This discriminator is how you tell rack-attack whether you're limiting per IP address, per user email or any other. The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). E.g. ```ruby # config/initializers/rack_attack.rb (for rails apps) Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request| request.ip end # Throttle login attempts for a given email parameter to 6 reqs/minute # Return the *normalized* email as a discriminator on POST /login requests Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req| if req.path == '/login' && req.post? # Normalize the email, using the same logic as your authentication process, to # protect against rate limit bypasses. req.params['email'].to_s.downcase.gsub(/\s+/, "") end end # You can also set a limit and period using a proc. For instance, after # Rack::Auth::Basic has authenticated the user: limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 } period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 } Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request| request.ip end ``` ### Tracks ```ruby # Track requests from a special user agent. Rack::Attack.track("special_agent") do |req| req.user_agent == "SpecialAgent" end # Supports optional limit and period, triggers the notification only when the limit is reached. Rack::Attack.track("special_agent", limit: 6, period: 60) do |req| req.user_agent == "SpecialAgent" end # Track it using ActiveSupport::Notification ActiveSupport::Notifications.subscribe("track.rack_attack") do |name, start, finish, instrumenter_id, payload| req = payload[:request] if req.env['rack.attack.matched'] == "special_agent" Rails.logger.info "special_agent: #{req.path}" STATSD.increment("special_agent") end end ``` ### Cache store configuration Throttle, track, allow2ban and fail2ban state is stored in a configurable cache (which defaults to `Rails.cache` if present), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)). ```ruby # This is the default Rack::Attack.cache.store = Rails.cache # It is recommended to use a separate database for throttling/allow2ban/fail2ban. Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: "...") ``` Most applications should use a new, separate database used only for `rack-attack`. During an actual attack or periods of heavy load, this database will come under heavy load. Keeping it on a separate database instance will give you additional resilience and make sure that other functions (like caching for your application) don't go down. Note that `Rack::Attack.cache` is only used for throttling, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). This means that other cache stores which inherit from ActiveSupport::Cache::Store are also compatible. In-memory stores which are not backed by an external database, such as `ActiveSupport::Cache::MemoryStore.new`, will be mostly ineffective because each Ruby process in your deployment will have it's own state, effectively multiplying the number of requests each client can make by the number of Ruby processes you have deployed. ## Customizing responses Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC.rdoc). ```ruby Rack::Attack.blocklisted_responder = lambda do |request| # Using 503 because it may make attacker think that they have successfully # DOSed the site. Rack::Attack returns 403 for blocklists by default [ 503, {}, ['Blocked']] end Rack::Attack.throttled_responder = lambda do |request| # NB: you have access to the name and other data about the matched throttle # request.env['rack.attack.matched'], # request.env['rack.attack.match_type'], # request.env['rack.attack.match_data'], # request.env['rack.attack.match_discriminator'] # Using 503 because it may make attacker think that they have successfully # DOSed the site. Rack::Attack returns 429 for throttling by default [ 503, {}, ["Server Error\n"]] end ``` ### RateLimit headers for well-behaved clients While Rack::Attack's primary focus is minimizing harm from abusive clients, it can also be used to return rate limit data that's helpful for well-behaved clients. If you want to return to user how many seconds to wait until they can start sending requests again, this can be done through enabling `Retry-After` header: ```ruby Rack::Attack.throttled_response_retry_after_header = true ``` Here's an example response that includes conventional `RateLimit-*` headers: ```ruby Rack::Attack.throttled_responder = lambda do |request| match_data = request.env['rack.attack.match_data'] now = match_data[:epoch_time] headers = { 'RateLimit-Limit' => match_data[:limit].to_s, 'RateLimit-Remaining' => '0', 'RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s } [ 429, headers, ["Throttled\n"]] end ``` For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data: ```ruby request.env['rack.attack.throttle_data'][name] # => { discriminator: d, count: n, period: p, limit: l, epoch_time: t } ``` ## Logging & Instrumentation Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available. You can subscribe to `rack_attack` events and log it, graph it, etc. To get notified about specific type of events, subscribe to the event name followed by the `rack_attack` namespace. E.g. for throttles use: ```ruby ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, instrumenter_id, payload| # request object available in payload[:request] # Your code here end ``` If you want to subscribe to every `rack_attack` event, use: ```ruby ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, instrumenter_id, payload| # request object available in payload[:request] # Your code here end ``` ## Testing A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will need to enable the cache in your development environment. See [Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html) for more on how to do this. ### Disabling `Rack::Attack.enabled = false` can be used to either completely disable Rack::Attack in your tests, or to disable/enable for specific test cases only. ### Test case isolation `Rack::Attack.reset!` can be used in your test suite to clear any Rack::Attack state between different test cases. If you're testing blocklist and safelist configurations, consider using `Rack::Attack.clear_configuration` to unset the values for those lists between test cases. ## How it works The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default. * If the request matches any **safelist**, it is allowed. * Otherwise, if the request matches any **blocklist**, it is blocked. * Otherwise, if the request matches any **throttle**, a counter is incremented in the Rack::Attack.cache. If any throttle's limit is exceeded, the request is blocked. * Otherwise, all **tracks** are checked, and the request is allowed. The algorithm is actually more concise in code: See [Rack::Attack.call](lib/rack/attack.rb): ```ruby def call(env) req = Rack::Attack::Request.new(env) if safelisted?(req) @app.call(env) elsif blocklisted?(req) self.class.blocklisted_responder.call(req) elsif throttled?(req) self.class.throttled_responder.call(req) else tracked?(req) @app.call(env) end end ``` Note: `Rack::Attack::Request` is just a subclass of `Rack::Request` so that you can cleanly monkey patch helper methods onto the [request object](lib/rack/attack/request.rb). ### About Tracks `Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes. ## Performance The overhead of running Rack::Attack is typically negligible (a few milliseconds per request), but it depends on how many checks you've configured, and how long they take. Throttles usually require a network roundtrip to your cache server(s), so try to keep the number of throttle checks per request low. If a request is blocklisted or throttled, the response is a very simple Rack response. A single typical ruby web server thread can block several hundred requests per second. Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone). ## Motivation Abusive clients range from malicious login crackers to naively-written scrapers. They hinder the security, performance, & availability of web applications. It is impractical if not impossible to block abusive clients completely. Rack::Attack aims to let developers quickly mitigate abusive requests and rely less on short-term, one-off hacks to block a particular attack. ## Contributing Check out the [Contributing guide](CONTRIBUTING.md). ## Code of Conduct This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). ## Development setup Check out the [Development guide](docs/development.md). ## License Copyright Kickstarter, PBC. Released under an [MIT License](https://opensource.org/licenses/MIT).